lib

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

commit 0d47e5ad9c47d6cc3c196b7da38a63188d0ff311
parent 46ff7c47d51581b89baa84fc6e9caf06aff7d473
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 07:38:36 +0000

transport: seal core outcomes and receipts

- hide outcome and target-receipt fields behind validated accessors
- bound outcome codes messages receipt counts and aggregate diagnostics
- reject duplicate receipt identities before deterministic map ordering
- migrate Runtime Nostr and Reticulum consumers to the sealed API

Diffstat:
Mcrates/runtime/src/transport.rs | 12++++++------
Mcrates/transport/src/delivery.rs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/transport/src/lib.rs | 1+
Mcrates/transport/src/limits.rs | 4++++
Mcrates/transport/src/serde_bounds.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/src/status.rs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/transport/tests/transport.rs | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/transport_nostr/src/outbox.rs | 50+++++++++++++++++++++++++++++---------------------
Mcrates/transport_nostr/src/outcome.rs | 12+++++++-----
Mcrates/transport_nostr/src/publish.rs | 35+++++++++++++++++++----------------
Mcrates/transport_nostr/tests/phase1_outbox_publication.rs | 4++--
Mcrates/transport_nostr/tests/transport.rs | 95+++++++++++++++++++++++++++++++------------------------------------------------
Mcrates/transport_reticulum/src/lib.rs | 33+++++++++++++++------------------
Mcrates/transport_reticulum/tests/reticulum.rs | 30+++++++++++++++---------------
14 files changed, 554 insertions(+), 249 deletions(-)

diff --git a/crates/runtime/src/transport.rs b/crates/runtime/src/transport.rs @@ -531,10 +531,10 @@ impl<'a> RadrootsRuntimeDeliveryWorker<'a> { })?; for target_receipt in receipt.target_receipts().iter().cloned() { target_states.insert( - target_receipt.target.fingerprint().as_str().to_owned(), + target_receipt.target().fingerprint().as_str().to_owned(), RadrootsRuntimeDeliveryTargetState { - target: target_receipt.target.clone(), - status: target_receipt.status, + target: target_receipt.target().clone(), + status: target_receipt.status(), }, ); plan_target_receipts.push(target_receipt); @@ -1005,7 +1005,7 @@ mod tests { 1 ); assert_eq!( - receipt.target_receipts()[0].status, + receipt.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::Accepted ); } @@ -1087,7 +1087,7 @@ mod tests { 0 ); assert_eq!( - receipt.target_receipts()[0].status, + receipt.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); } @@ -1160,7 +1160,7 @@ mod tests { ); assert_eq!(receipt.target_receipts.len(), 1); assert_eq!( - receipt.target_receipts[0].status, + receipt.target_receipts[0].status(), RadrootsTransportDeliveryTargetStatus::Accepted ); } diff --git a/crates/transport/src/delivery.rs b/crates/transport/src/delivery.rs @@ -1,8 +1,9 @@ use crate::{ - RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT, - RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportOutcome, - RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportTarget, - RadrootsTransportTargetFingerprint, RadrootsTransportTargetSet, + RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, + RADROOTS_TRANSPORT_TARGET_MAX_COUNT, RadrootsTransportDeliveryTargetStatus, + RadrootsTransportError, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, + RadrootsTransportPayload, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, + RadrootsTransportTargetSet, }; use alloc::collections::{BTreeMap, BTreeSet}; use alloc::string::String; @@ -458,6 +459,7 @@ fn validate_delivery_timestamp(now_ms: i64) -> Result<(), RadrootsTransportError #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct RadrootsTransportDeliveryRequestWire { + #[serde(deserialize_with = "deserialize_delivery_request_id")] request_id: String, payload: RadrootsTransportPayload, target_set: RadrootsTransportTargetSet, @@ -486,10 +488,10 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportDeliveryRequest { #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTransportTargetReceipt { - pub target: RadrootsTransportTarget, - pub attempted: bool, - pub status: RadrootsTransportDeliveryTargetStatus, - pub outcome: RadrootsTransportOutcome, + target: RadrootsTransportTarget, + attempted: bool, + status: RadrootsTransportDeliveryTargetStatus, + outcome: RadrootsTransportOutcome, } impl RadrootsTransportTargetReceipt { @@ -501,28 +503,49 @@ impl RadrootsTransportTargetReceipt { Self { target, attempted: true, - status: outcome.status, + status: outcome.status(), outcome, } } - pub fn skipped(target: RadrootsTransportTarget, outcome: RadrootsTransportOutcome) -> Self { - Self { + pub fn skipped( + target: RadrootsTransportTarget, + outcome: RadrootsTransportOutcome, + ) -> Result<Self, RadrootsTransportError> { + let receipt = Self { target, attempted: false, - status: outcome.status, + status: outcome.status(), outcome, - } + }; + receipt.validate()?; + Ok(receipt) + } + + pub fn target(&self) -> &RadrootsTransportTarget { + &self.target + } + + pub fn was_attempted(&self) -> bool { + self.attempted + } + + pub fn status(&self) -> RadrootsTransportDeliveryTargetStatus { + self.status + } + + pub fn outcome(&self) -> &RadrootsTransportOutcome { + &self.outcome } fn validate(&self) -> Result<(), RadrootsTransportError> { self.outcome.validate()?; - if self.status != self.outcome.status { + if self.status != self.outcome.status() { return Err(RadrootsTransportError::DeliveryTargetReceiptStatusMismatch); } if !self.attempted && self.status.counts_as_accepted_satisfaction() - && self.outcome.kind != RadrootsTransportOutcomeKind::DuplicateAccepted + && self.outcome.kind() != RadrootsTransportOutcomeKind::DuplicateAccepted { return Err(RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch); } @@ -585,32 +608,55 @@ impl RadrootsTransportDeliveryReceipt { ) -> Result<Self, RadrootsTransportError> { let request_id = request_id.into(); validate_delivery_request_id(request_id.as_str())?; - - let mut receipts_by_fingerprint: BTreeMap<String, RadrootsTransportTargetReceipt> = - BTreeMap::new(); - for receipt in target_receipts { + crate::limits::ensure_resource_limit( + "delivery_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: "delivery_diagnostic_bytes", + max: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + actual: usize::MAX, + })?; + crate::limits::ensure_resource_limit( + "delivery_diagnostic_bytes", + diagnostic_bytes, + RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + )?; let Some(requested_target) = target_set .targets() .iter() - .find(|target| target.fingerprint() == receipt.target.fingerprint()) + .find(|target| target.fingerprint() == receipt.target().fingerprint()) else { return Err(RadrootsTransportError::UnexpectedDeliveryTargetReceipt); }; - if requested_target != &receipt.target { + if requested_target != receipt.target() { return Err(RadrootsTransportError::UnexpectedDeliveryTargetReceipt); } - if receipts_by_fingerprint - .insert(receipt.target.fingerprint().as_str().into(), receipt) - .is_some() - { + if !receipt_fingerprints.insert(receipt.target().fingerprint().as_str()) { return Err(RadrootsTransportError::DuplicateDeliveryTargetReceipt); } } - if receipts_by_fingerprint.len() != target_set.len() { + if receipt_fingerprints.len() != target_set.len() { return Err(RadrootsTransportError::MissingDeliveryTargetReceipt); } + let mut receipts_by_fingerprint = target_receipts + .into_iter() + .map(|receipt| { + ( + String::from(receipt.target().fingerprint().as_str()), + receipt, + ) + }) + .collect::<BTreeMap<_, _>>(); let target_receipts = target_set .targets() .iter() @@ -659,7 +705,7 @@ impl RadrootsTransportDeliveryReceipt { ) -> usize { self.target_receipts .iter() - .filter(|receipt| receipt.status.counts_as_satisfied(satisfaction_class)) + .filter(|receipt| receipt.status().counts_as_satisfied(satisfaction_class)) .count() } @@ -681,8 +727,8 @@ impl RadrootsTransportDeliveryReceipt { validate_required_targets(targets)?; Ok(targets.iter().all(|required| { self.target_receipts.iter().any(|receipt| { - receipt.target.fingerprint() == required - && receipt.status.counts_as_satisfied(class) + receipt.target().fingerprint() == required + && receipt.status().counts_as_satisfied(class) }) })) } @@ -692,12 +738,40 @@ impl RadrootsTransportDeliveryReceipt { #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct RadrootsTransportDeliveryReceiptWire { + #[serde(deserialize_with = "deserialize_delivery_request_id")] request_id: String, target_set: RadrootsTransportTargetSet, + #[serde(deserialize_with = "deserialize_delivery_target_receipts")] target_receipts: Vec<RadrootsTransportTargetReceipt>, } #[cfg(feature = "serde")] +fn deserialize_delivery_request_id<'de, D>(deserializer: D) -> Result<String, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_string( + deserializer, + "delivery_request_id", + RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, + ) +} + +#[cfg(feature = "serde")] +fn deserialize_delivery_target_receipts<'de, D>( + deserializer: D, +) -> Result<Vec<RadrootsTransportTargetReceipt>, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_vec( + deserializer, + "delivery_target_receipt_count", + RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + ) +} + +#[cfg(feature = "serde")] impl<'de> serde::Deserialize<'de> for RadrootsTransportDeliveryReceipt { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -34,6 +34,7 @@ pub use limits::{ RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT, RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES, RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT, RADROOTS_TRANSPORT_FETCH_RAW_JSON_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, RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS, diff --git a/crates/transport/src/limits.rs b/crates/transport/src/limits.rs @@ -16,6 +16,10 @@ pub const RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT: usize = 1_000; pub const RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT: usize = 4_096; pub const RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES: usize = 64 * 1024 * 1024; pub const RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES: usize = 4_096; +pub const RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES: usize = + RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES; +pub const RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES: usize = + RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES; pub const RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS: u64 = 30_000; pub(crate) fn ensure_resource_limit( diff --git a/crates/transport/src/serde_bounds.rs b/crates/transport/src/serde_bounds.rs @@ -32,6 +32,17 @@ where }) } +pub(crate) fn deserialize_option_string<'de, D>( + deserializer: D, + field: &'static str, + max: usize, +) -> Result<Option<String>, D::Error> +where + D: serde::Deserializer<'de>, +{ + deserializer.deserialize_option(BoundedOptionStringVisitor { field, max }) +} + struct BoundedStringVisitor { field: &'static str, max: usize, @@ -95,6 +106,44 @@ struct BoundedVecVisitor<T> { marker: PhantomData<T>, } +struct BoundedOptionStringVisitor { + field: &'static str, + max: usize, +} + +impl<'de> Visitor<'de> for BoundedOptionStringVisitor { + type Value = Option<String>; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "an optional string of at most {} UTF-8 bytes", + self.max + ) + } + + fn visit_none<E>(self) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + Ok(None) + } + + fn visit_unit<E>(self) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + Ok(None) + } + + fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error> + where + D: serde::Deserializer<'de>, + { + deserialize_string(deserializer, self.field, self.max).map(Some) + } +} + impl<'de, T> Visitor<'de> for BoundedVecVisitor<T> where T: serde::Deserialize<'de>, diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs @@ -1,4 +1,5 @@ use crate::{ + RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES, RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, RadrootsTransportImplementationState, RadrootsTransportKind, delivery::RadrootsTransportSatisfactionClass, @@ -143,10 +144,10 @@ impl RadrootsTransportOutcomeKind { #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTransportOutcome { - pub kind: RadrootsTransportOutcomeKind, - pub status: RadrootsTransportDeliveryTargetStatus, - pub code: Option<String>, - pub message: Option<String>, + kind: RadrootsTransportOutcomeKind, + status: RadrootsTransportDeliveryTargetStatus, + code: Option<String>, + message: Option<String>, } impl RadrootsTransportOutcome { @@ -159,20 +160,68 @@ impl RadrootsTransportOutcome { } } - pub fn with_message(mut self, message: impl Into<String>) -> Self { - self.message = Some(message.into()); - self + pub fn try_with_message( + mut self, + message: impl Into<String>, + ) -> Result<Self, crate::RadrootsTransportError> { + let message = message.into(); + crate::limits::ensure_resource_limit( + "transport_outcome_message", + message.len(), + RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES, + )?; + self.message = Some(message); + Ok(self) } - pub fn with_code(mut self, code: impl Into<String>) -> Self { - self.code = Some(code.into()); - self + pub fn try_with_code( + mut self, + code: impl Into<String>, + ) -> Result<Self, crate::RadrootsTransportError> { + let code = code.into(); + crate::limits::ensure_resource_limit( + "transport_outcome_code", + code.len(), + RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, + )?; + self.code = Some(code); + Ok(self) + } + + pub fn kind(&self) -> RadrootsTransportOutcomeKind { + self.kind + } + + pub fn status(&self) -> RadrootsTransportDeliveryTargetStatus { + self.status + } + + pub fn code(&self) -> Option<&str> { + self.code.as_deref() + } + + pub fn message(&self) -> Option<&str> { + self.message.as_deref() } pub fn validate(&self) -> Result<(), crate::RadrootsTransportError> { if self.status != self.kind.target_status() { return Err(crate::RadrootsTransportError::TransportOutcomeStatusMismatch); } + if let Some(code) = &self.code { + crate::limits::ensure_resource_limit( + "transport_outcome_code", + code.len(), + RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, + )?; + } + if let Some(message) = &self.message { + crate::limits::ensure_resource_limit( + "transport_outcome_message", + message.len(), + RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES, + )?; + } Ok(()) } } @@ -183,11 +232,37 @@ impl RadrootsTransportOutcome { struct RadrootsTransportOutcomeWire { kind: RadrootsTransportOutcomeKind, status: RadrootsTransportDeliveryTargetStatus, + #[serde(deserialize_with = "deserialize_outcome_code")] code: Option<String>, + #[serde(deserialize_with = "deserialize_outcome_message")] message: Option<String>, } #[cfg(feature = "serde")] +fn deserialize_outcome_code<'de, D>(deserializer: D) -> Result<Option<String>, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_option_string( + deserializer, + "transport_outcome_code", + RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, + ) +} + +#[cfg(feature = "serde")] +fn deserialize_outcome_message<'de, D>(deserializer: D) -> Result<Option<String>, D::Error> +where + D: serde::Deserializer<'de>, +{ + crate::serde_bounds::deserialize_option_string( + deserializer, + "transport_outcome_message", + RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES, + ) +} + +#[cfg(feature = "serde")] impl<'de> serde::Deserialize<'de> for RadrootsTransportOutcome { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,23 +1,24 @@ use radroots_transport::{ RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID, - RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, - RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_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_DELIVERY_REQUEST_ID_MAX_BYTES, RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + RADROOTS_TRANSPORT_ENDPOINT_URI_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, RadrootsTransportSatisfactionPolicy, RadrootsTransportSatisfactionPolicyKind, RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, - RadrootsTransportTargetLabel, - RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, - ReticulumCapabilityReportV1, ReticulumDestinationV1, ReticulumDuplicateFragmentBehaviorV1, - ReticulumFragmentIntegrityV1, ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, - ReticulumPrivacySemanticsV1, + RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, + RadrootsTransportTargetUri, ReticulumCapabilityReportV1, ReticulumDestinationV1, + ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1, + ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, ReticulumPrivacySemanticsV1, }; use serde_json::Value; use std::borrow::ToOwned; @@ -1101,10 +1102,12 @@ fn typed_outcome_kinds_drive_status_and_satisfaction_semantics() { ]; for (kind, label, status, satisfied_classes) in cases { - let outcome = RadrootsTransportOutcome::new(kind).with_message("transport detail"); + let outcome = RadrootsTransportOutcome::new(kind) + .try_with_message("transport detail") + .expect("bounded transport detail"); assert_eq!(kind.as_str(), label); - assert_eq!(outcome.kind, kind); - assert_eq!(outcome.status, status); + assert_eq!(outcome.kind(), kind); + assert_eq!(outcome.status(), status); for class in classes { assert_eq!( kind.counts_as_satisfied(class), @@ -1112,13 +1115,13 @@ fn typed_outcome_kinds_drive_status_and_satisfaction_semantics() { "{kind:?} / {class:?}" ); } - assert_eq!(outcome.message.as_deref(), Some("transport detail")); + assert_eq!(outcome.message(), Some("transport detail")); } let deferred_until_implemented = RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::DeferredUntilImplemented); assert_eq!( - deferred_until_implemented.status, + deferred_until_implemented.status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); deferred_until_implemented @@ -1318,7 +1321,7 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() { ) .expect("deliver"); assert_eq!( - delivery.target_receipts()[0].outcome.kind, + delivery.target_receipts()[0].outcome().kind(), RadrootsTransportOutcomeKind::Delivered ); let fetch = futures::executor::block_on( @@ -1327,7 +1330,7 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() { .expect("fetch"); assert_eq!(fetch.fetched_count, 1); assert_eq!( - fetch.target_receipts[0].outcome.kind, + fetch.target_receipts[0].outcome().kind(), RadrootsTransportOutcomeKind::Seen ); } @@ -1538,47 +1541,10 @@ fn delivery_requests_and_receipts_reject_forged_identity_and_cardinality() { .expect_err("unexpected receipt"), RadrootsTransportError::UnexpectedDeliveryTargetReceipt ); - let mut mismatched_status = accepted_second.clone(); - mismatched_status.status = RadrootsTransportDeliveryTargetStatus::Pending; assert_eq!( - RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![accepted_first.clone(), mismatched_status], - ) - .expect_err("status mismatch"), - RadrootsTransportError::DeliveryTargetReceiptStatusMismatch - ); - let mut forged_outcome = RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted); - forged_outcome.status = RadrootsTransportDeliveryTargetStatus::Pending; - assert_eq!( - RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![ - accepted_first.clone(), - RadrootsTransportTargetReceipt { - target: second.clone(), - attempted: true, - status: RadrootsTransportDeliveryTargetStatus::Pending, - outcome: forged_outcome, - }, - ], - ) - .expect_err("outcome status mismatch"), - RadrootsTransportError::TransportOutcomeStatusMismatch - ); - assert_eq!( - RadrootsTransportDeliveryReceipt::new( - "request", - targets.clone(), - vec![ - accepted_first.clone(), - RadrootsTransportTargetReceipt::skipped( - second.clone(), - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - ), - ], + RadrootsTransportTargetReceipt::skipped( + second.clone(), + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), ) .expect_err("skipped accepted outcome"), RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch @@ -1590,8 +1556,8 @@ fn delivery_requests_and_receipts_reject_forged_identity_and_cardinality() { vec![accepted_second, accepted_first], ) .expect("canonical receipt"); - assert_eq!(receipt.target_receipts()[0].target, first); - assert_eq!(receipt.target_receipts()[1].target, second); + assert_eq!(receipt.target_receipts()[0].target(), &first); + assert_eq!(receipt.target_receipts()[1].target(), &second); receipt .validate_for_request(&request) .expect("matching request"); @@ -1666,6 +1632,68 @@ fn delivery_request_and_receipt_deserialization_revalidates_invariants() { direct_attempt_wire["attempted"] = Value::Bool(false); assert!(serde_json::from_value::<RadrootsTransportTargetReceipt>(direct_attempt_wire).is_err()); + let exact_outcome_wire = serde_json::json!({ + "kind": "Accepted", + "status": "Accepted", + "code": "c".repeat(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES), + "message": "m".repeat(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES), + }); + let exact_outcome_json = + serde_json::to_string(&exact_outcome_wire).expect("exact outcome JSON"); + serde_json::from_str::<RadrootsTransportOutcome>(&exact_outcome_json) + .expect("decode exact outcome wire"); + for (field, value, expected_limit) in [ + ( + "code", + "c".repeat(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES + 1), + "transport_outcome_code", + ), + ( + "message", + "m".repeat(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES + 1), + "transport_outcome_message", + ), + ] { + let mut one_over = exact_outcome_wire.clone(); + one_over[field] = Value::String(value); + let encoded = serde_json::to_string(&one_over).expect("one-over outcome JSON"); + assert!( + serde_json::from_str::<RadrootsTransportOutcome>(&encoded) + .expect_err("reject one-over outcome wire") + .to_string() + .contains(expected_limit) + ); + } + let mut unknown_outcome = exact_outcome_wire; + unknown_outcome["unknown"] = Value::Bool(true); + assert!(serde_json::from_value::<RadrootsTransportOutcome>(unknown_outcome).is_err()); + + let mut exact_request_id_wire = serde_json::to_value(&receipt).expect("receipt wire"); + exact_request_id_wire["request_id"] = + Value::String("r".repeat(RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES)); + serde_json::from_value::<RadrootsTransportDeliveryReceipt>(exact_request_id_wire) + .expect("decode exact receipt request id"); + let mut one_over_request_id_wire = serde_json::to_value(&receipt).expect("receipt wire"); + one_over_request_id_wire["request_id"] = + Value::String("r".repeat(RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES + 1)); + assert!( + serde_json::from_value::<RadrootsTransportDeliveryReceipt>(one_over_request_id_wire) + .expect_err("reject one-over receipt request id") + .to_string() + .contains("delivery_request_id") + ); + + let mut one_over_receipts_wire = serde_json::to_value(&receipt).expect("receipt wire"); + let receipt_item = one_over_receipts_wire["target_receipts"][0].clone(); + one_over_receipts_wire["target_receipts"] = + Value::Array(vec![receipt_item; RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1]); + assert!( + serde_json::from_value::<RadrootsTransportDeliveryReceipt>(one_over_receipts_wire) + .expect_err("reject one-over receipt collection before identity validation") + .to_string() + .contains("delivery_target_receipt_count") + ); + let mut payload_wire = serde_json::to_value(opaque_payload()).expect("payload wire"); payload_wire["OpaqueBytes"]["digest"] = Value::String("0".repeat(64)); assert!(serde_json::from_value::<RadrootsTransportPayload>(payload_wire).is_err()); @@ -1844,10 +1872,11 @@ fn payload_contract_covers_all_validation_boundaries() { fn status_contract_covers_builders_and_availability_defaults() { assert_eq!( RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) - .with_code("accepted") - .with_message("accepted by transport") - .code - .as_deref(), + .try_with_code("accepted") + .expect("bounded code") + .try_with_message("accepted by transport") + .expect("bounded message") + .code(), Some("accepted") ); @@ -2205,6 +2234,90 @@ fn transport_bounds_targets_enforce_exact_and_one_over_before_set_work() { } #[test] +fn transport_bounds_outcomes_and_receipts_enforce_exact_and_one_over() { + let exact_code = "c".repeat(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES); + let exact_message = "m".repeat(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES); + let exact_outcome = RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) + .try_with_code(exact_code) + .expect("exact outcome code") + .try_with_message(exact_message) + .expect("exact outcome message"); + assert_eq!( + exact_outcome.code().map(str::len), + Some(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES) + ); + assert_eq!( + exact_outcome.message().map(str::len), + Some(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES) + ); + assert_eq!( + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) + .try_with_code("c".repeat(RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES + 1)) + .expect_err("one-over outcome code"), + RadrootsTransportError::ResourceLimitExceeded { + field: "transport_outcome_code", + max: RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, + actual: RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES + 1, + } + ); + assert_eq!( + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) + .try_with_message("m".repeat(RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES + 1)) + .expect_err("one-over outcome message"), + RadrootsTransportError::ResourceLimitExceeded { + field: "transport_outcome_message", + max: RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES, + actual: RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES + 1, + } + ); + + let first = RadrootsTransportTarget::local("local:diagnostic-first").expect("first target"); + let second = RadrootsTransportTarget::local("local:diagnostic-second").expect("second target"); + let target_set = RadrootsTransportTargetSet::new(vec![first.clone(), second.clone()]) + .expect("diagnostic target set"); + let half = RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES / 2; + let exact_receipts = vec![ + RadrootsTransportTargetReceipt::new( + first.clone(), + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) + .try_with_message("a".repeat(half)) + .expect("first diagnostic"), + ), + RadrootsTransportTargetReceipt::new( + second.clone(), + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) + .try_with_message("b".repeat(half)) + .expect("second diagnostic"), + ), + ]; + RadrootsTransportDeliveryReceipt::new("request", target_set.clone(), exact_receipts) + .expect("exact aggregate diagnostic budget"); + let one_over_receipts = vec![ + RadrootsTransportTargetReceipt::new( + first, + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) + .try_with_message("a".repeat(half)) + .expect("first diagnostic"), + ), + RadrootsTransportTargetReceipt::new( + second, + RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted) + .try_with_message("b".repeat(half + 1)) + .expect("second diagnostic"), + ), + ]; + assert_eq!( + RadrootsTransportDeliveryReceipt::new("request", target_set, one_over_receipts) + .expect_err("one-over aggregate diagnostic budget"), + RadrootsTransportError::ResourceLimitExceeded { + field: "delivery_diagnostic_bytes", + max: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + actual: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1, + } + ); +} + +#[test] fn transport_bounds_payloads_enforce_exact_and_one_over_before_copying() { let exact_json = format!( "{{{}}}", diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -207,8 +207,9 @@ where for relay in &publish.relays { if relay .outcome - .to_transport_outcome() - .status + .kind + .transport_outcome_kind() + .target_status() .counts_as_satisfied(RadrootsTransportSatisfactionClass::Accepted) && publishable .targets_for_relay(relay.relay_url.as_str()) @@ -355,8 +356,9 @@ where for relay in &relay_receipts { if relay .outcome - .to_transport_outcome() - .status + .kind + .transport_outcome_kind() + .target_status() .counts_as_satisfied(RadrootsTransportSatisfactionClass::Accepted) && publishable .targets_for_relay(relay.relay_url.as_str()) @@ -518,7 +520,11 @@ fn target_receipts_from_relay_receipts( target_scope: target.target_scope.clone(), target_label: target.target_label.clone(), attempted: relay_receipt.attempted, - transport_status: relay_receipt.outcome.to_transport_outcome().status, + transport_status: relay_receipt + .outcome + .kind + .transport_outcome_kind() + .target_status(), outcome: relay_receipt.outcome.clone(), }); } @@ -537,16 +543,16 @@ fn target_receipts_from_transport_receipts( publishable .relays .iter() - .find(|target| target.endpoint_fingerprint == *receipt.target.fingerprint()) + .find(|target| target.endpoint_fingerprint == *receipt.target().fingerprint()) .map(|target| RadrootsOutboxPublishTargetReceipt { delivery_target_id: target.delivery_target_id, endpoint_uri: target.relay_url.clone(), endpoint_fingerprint: target.endpoint_fingerprint.clone(), target_scope: target.target_scope.clone(), target_label: target.target_label.clone(), - attempted: receipt.attempted, - transport_status: receipt.status, - outcome: relay_outcome_from_transport_outcome(&receipt.outcome), + attempted: receipt.was_attempted(), + transport_status: receipt.status(), + outcome: relay_outcome_from_transport_outcome(receipt.outcome()), }) }) .collect() @@ -684,11 +690,11 @@ fn relay_receipts_from_transport_receipts( ) -> Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError> { let mut relay_receipts: Vec<RadrootsRelayPublishRelayReceipt> = Vec::new(); for receipt in delivery.target_receipts() { - let outcome = relay_outcome_from_transport_outcome(&receipt.outcome); - let relay_receipt = if receipt.attempted { - RadrootsRelayPublishRelayReceipt::attempted(receipt.target.uri().as_str(), outcome) + let outcome = relay_outcome_from_transport_outcome(receipt.outcome()); + let relay_receipt = if receipt.was_attempted() { + RadrootsRelayPublishRelayReceipt::attempted(receipt.target().uri().as_str(), outcome) } else { - RadrootsRelayPublishRelayReceipt::skipped(receipt.target.uri().as_str(), outcome) + RadrootsRelayPublishRelayReceipt::skipped(receipt.target().uri().as_str(), outcome) }; if let Some(existing) = relay_receipts .iter() @@ -712,13 +718,12 @@ fn relay_outcome_from_transport_outcome( outcome: &RadrootsTransportOutcome, ) -> RadrootsRelayOutcome { let kind = outcome - .code - .as_deref() + .code() .and_then(relay_outcome_kind_from_code) - .unwrap_or_else(|| relay_outcome_kind_from_transport_outcome(outcome.kind)); + .unwrap_or_else(|| relay_outcome_kind_from_transport_outcome(outcome.kind())); RadrootsRelayOutcome { kind, - message: outcome.message.clone(), + message: outcome.message().map(str::to_owned), } } @@ -1363,10 +1368,11 @@ mod tests { relay_kind ); let outcome = RadrootsTransportOutcome::new(transport_kind) - .with_message(format!("{transport_kind:?}")); + .try_with_message(format!("{transport_kind:?}")) + .expect("bounded test outcome message"); let relay_outcome = relay_outcome_from_transport_outcome(&outcome); assert_eq!(relay_outcome.kind, relay_kind); - assert_eq!(relay_outcome.message, outcome.message); + assert_eq!(relay_outcome.message.as_deref(), outcome.message()); } let code_cases = [ @@ -1408,7 +1414,8 @@ mod tests { assert_eq!( relay_outcome_from_transport_outcome( &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Rejected) - .with_code(code) + .try_with_code(code) + .expect("bounded test outcome code") ) .kind, relay_kind @@ -1418,7 +1425,8 @@ mod tests { assert_eq!( relay_outcome_from_transport_outcome( &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen) - .with_code("unrecognized") + .try_with_code("unrecognized") + .expect("bounded test outcome code") ) .kind, RadrootsRelayOutcomeKind::Accepted diff --git a/crates/transport_nostr/src/outcome.rs b/crates/transport_nostr/src/outcome.rs @@ -1,6 +1,8 @@ #![forbid(unsafe_code)] -use radroots_transport::{RadrootsTransportOutcome, RadrootsTransportOutcomeKind}; +use radroots_transport::{ + RadrootsTransportError, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, +}; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -206,12 +208,12 @@ impl RadrootsRelayOutcome { self.kind.is_terminal_failure() } - pub fn to_transport_outcome(&self) -> RadrootsTransportOutcome { + pub fn to_transport_outcome(&self) -> Result<RadrootsTransportOutcome, RadrootsTransportError> { let mut outcome = RadrootsTransportOutcome::new(self.kind.transport_outcome_kind()) - .with_code(self.kind.as_str()); + .try_with_code(self.kind.as_str())?; if let Some(message) = &self.message { - outcome = outcome.with_message(message.clone()); + outcome = outcome.try_with_message(message.clone())?; } - outcome + Ok(outcome) } } diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -277,7 +277,7 @@ where transport_failure_target_receipts( request.target_set().targets(), message.as_str(), - ), + )?, ); } Err(error) => return Err(nostr_error_to_transport_error(error)), @@ -287,7 +287,7 @@ where target_receipts_from_relay_receipts( request.target_set().targets(), relay_receipts.as_slice(), - ), + )?, ) }) } @@ -609,7 +609,7 @@ fn relay_targets_from_transport_targets( fn target_receipts_from_relay_receipts( targets: &[RadrootsTransportTarget], relay_receipts: &[RadrootsRelayPublishRelayReceipt], -) -> Vec<RadrootsTransportTargetReceipt> { +) -> Result<Vec<RadrootsTransportTargetReceipt>, RadrootsTransportError> { targets .iter() .cloned() @@ -617,21 +617,22 @@ fn target_receipts_from_relay_receipts( let relay_receipt = relay_receipts .iter() .find(|receipt| relay_receipt_matches_target(receipt, &target)); - match relay_receipt { + let receipt = match relay_receipt { Some(receipt) if receipt.attempted => RadrootsTransportTargetReceipt::attempted( target, - receipt.outcome.to_transport_outcome(), + receipt.outcome.to_transport_outcome()?, ), Some(receipt) => RadrootsTransportTargetReceipt::skipped( target, - receipt.outcome.to_transport_outcome(), - ), + receipt.outcome.to_transport_outcome()?, + )?, None => RadrootsTransportTargetReceipt::skipped( target, RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::RouteUnavailable) - .with_message("relay adapter omitted target receipt"), - ), - } + .try_with_message("relay adapter omitted target receipt")?, + )?, + }; + Ok(receipt) }) .collect() } @@ -639,7 +640,7 @@ fn target_receipts_from_relay_receipts( fn transport_failure_target_receipts( targets: &[RadrootsTransportTarget], message: &str, -) -> Vec<RadrootsTransportTargetReceipt> { +) -> Result<Vec<RadrootsTransportTargetReceipt>, RadrootsTransportError> { targets .iter() .cloned() @@ -647,7 +648,7 @@ fn transport_failure_target_receipts( RadrootsTransportTargetReceipt::skipped( target, RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::ConnectionFailed) - .with_message(message.to_owned()), + .try_with_message(message.to_owned())?, ) }) .collect() @@ -767,8 +768,9 @@ fn relay_publish_satisfies_policy( relay_receipt_counts_toward_quorum(receipt) && receipt .outcome - .to_transport_outcome() - .status + .kind + .transport_outcome_kind() + .target_status() .counts_as_satisfied(class) }) .count(); @@ -782,8 +784,9 @@ fn relay_publish_satisfies_policy( && relay_receipt_counts_toward_quorum(receipt) && receipt .outcome - .to_transport_outcome() - .status + .kind + .transport_outcome_kind() + .target_status() .counts_as_satisfied(class) { satisfied_required_targets.insert(target.fingerprint().clone()); diff --git a/crates/transport_nostr/tests/phase1_outbox_publication.rs b/crates/transport_nostr/tests/phase1_outbox_publication.rs @@ -186,7 +186,7 @@ async fn outbox_publication_all_seven_leaves_reuse_exact_bytes_and_dispatch_iden .await .unwrap(); assert_eq!( - first_receipt.target_receipts()[0].status, + first_receipt.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::FailedRetryable ); assert_eq!( @@ -229,7 +229,7 @@ async fn outbox_publication_all_seven_leaves_reuse_exact_bytes_and_dispatch_iden .await .unwrap(); assert_eq!( - retry_receipt.target_receipts()[0].status, + retry_receipt.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::Accepted ); assert_eq!( diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -1027,43 +1027,52 @@ fn outcome_prefix_classification_covers_required_kinds() { assert!(RadrootsRelayOutcome::relay_url_rejected("unsafe relay").is_terminal_failure()); assert!(RadrootsRelayOutcome::classify("mute: pubkey muted").is_terminal_failure()); assert_eq!( - RadrootsRelayOutcome::accepted().to_transport_outcome().kind, + RadrootsRelayOutcome::accepted() + .to_transport_outcome() + .expect("bounded outcome") + .kind(), radroots_transport::RadrootsTransportOutcomeKind::Accepted ); assert_eq!( RadrootsRelayOutcome::accepted() .to_transport_outcome() - .status, + .expect("bounded outcome") + .status(), radroots_transport::RadrootsTransportDeliveryTargetStatus::Accepted ); assert_eq!( RadrootsRelayOutcome::timeout("timeout: no OK") .to_transport_outcome() - .kind, + .expect("bounded outcome") + .kind(), radroots_transport::RadrootsTransportOutcomeKind::Timeout ); assert_eq!( RadrootsRelayOutcome::timeout("timeout: no OK") .to_transport_outcome() - .status, + .expect("bounded outcome") + .status(), radroots_transport::RadrootsTransportDeliveryTargetStatus::FailedRetryable ); assert_eq!( RadrootsRelayOutcome::classify("restricted: denied") .to_transport_outcome() - .kind, + .expect("bounded outcome") + .kind(), radroots_transport::RadrootsTransportOutcomeKind::Rejected ); assert_eq!( RadrootsRelayOutcome::classify("restricted: denied") .to_transport_outcome() - .status, + .expect("bounded outcome") + .status(), radroots_transport::RadrootsTransportDeliveryTargetStatus::FailedTerminal ); assert_eq!( RadrootsRelayOutcome::relay_url_rejected("unsafe") .to_transport_outcome() - .kind, + .expect("bounded outcome") + .kind(), radroots_transport::RadrootsTransportOutcomeKind::RouteUnavailable ); assert_eq!( @@ -1075,7 +1084,8 @@ fn outcome_prefix_classification_covers_required_kinds() { assert_eq!( RadrootsRelayOutcome::unknown("adapter omitted receipt") .to_transport_outcome() - .kind, + .expect("bounded outcome") + .kind(), radroots_transport::RadrootsTransportOutcomeKind::TransportUnavailable ); assert_eq!( @@ -1176,9 +1186,9 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() { assert_eq!(status, expected_status); assert_eq!(receipt.request_id(), "facade-request-1"); assert_eq!(receipt.target_receipts().len(), 1); - assert_eq!(receipt.target_receipts()[0].target, target); + assert_eq!(receipt.target_receipts()[0].target(), &target); assert_eq!( - receipt.target_receipts()[0].outcome.kind, + receipt.target_receipts()[0].outcome().kind(), radroots_transport::RadrootsTransportOutcomeKind::Accepted ); assert!( @@ -1418,8 +1428,8 @@ async fn nostr_transport_facade_preserves_adapter_failure_and_omission_evidence( .expect("failure receipts"); assert_eq!(failed.target_receipts().len(), 2); assert!(failed.target_receipts().iter().all(|receipt| { - receipt.outcome.kind == RadrootsTransportOutcomeKind::ConnectionFailed - && receipt.status == RadrootsTransportDeliveryTargetStatus::FailedRetryable + receipt.outcome().kind() == RadrootsTransportOutcomeKind::ConnectionFailed + && receipt.status() == RadrootsTransportDeliveryTargetStatus::FailedRetryable })); let partial = RadrootsNostrTransport::new(PartialPublishAdapter) @@ -1436,7 +1446,7 @@ async fn nostr_transport_facade_preserves_adapter_failure_and_omission_evidence( .expect("partial receipts"); assert_eq!(partial.target_receipts().len(), 2); assert_eq!( - partial.target_receipts()[1].outcome.kind, + partial.target_receipts()[1].outcome().kind(), RadrootsTransportOutcomeKind::TransportUnavailable ); @@ -1483,9 +1493,9 @@ async fn nostr_transport_facade_matches_canonical_equivalent_relay_receipts() { .expect("delivery"); assert_eq!(receipt.target_receipts().len(), 1); - assert_eq!(receipt.target_receipts()[0].target, target); + assert_eq!(receipt.target_receipts()[0].target(), &target); assert_eq!( - receipt.target_receipts()[0].status, + receipt.target_receipts()[0].status(), radroots_transport::RadrootsTransportDeliveryTargetStatus::Accepted ); assert!(receipt.is_satisfied_by(&policy).expect("satisfaction")); @@ -1530,8 +1540,8 @@ async fn nostr_transport_facade_preserves_scoped_duplicate_target_metadata() { let receipt = transport.deliver(request).await.expect("delivery"); assert_eq!(receipt.target_receipts().len(), 2); - assert_eq!(receipt.target_receipts()[0].target, first); - assert_eq!(receipt.target_receipts()[1].target, second); + assert_eq!(receipt.target_receipts()[0].target(), &first); + assert_eq!(receipt.target_receipts()[1].target(), &second); assert!(receipt.is_satisfied_by(&policy).expect("satisfaction")); assert_eq!(adapter.captured_raw_events().len(), 1); } @@ -3664,47 +3674,16 @@ async fn outbox_transport_facade_persists_every_delivery_status() { assert_outbox_publish_observations(&observations, 6); } -#[tokio::test] -async fn outbox_transport_facade_rejects_pending_receipts() { - let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); - let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = generic_draft("pending transport receipt"); - outbox - .enqueue_operation(all_accepted_outbox_operation_input( - draft, - [RELAY_PRIMARY_WSS], - )) - .await - .expect("enqueue"); - let claimed = outbox - .claim_next_ready_event("signer", "pending-sign", 2_000, 1_000) - .await - .expect("sign claim") - .expect("sign claim"); - complete_claimed_signing(&outbox, &claimed, 1_100).await; - let publish_claim = outbox - .claim_next_ready_event("publisher", "pending-publish", 3_000, 1_100) - .await - .expect("publish claim") - .expect("publish claim"); - let mut forged_outcome = RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted); - forged_outcome.status = RadrootsTransportDeliveryTargetStatus::Pending; - let transport = ScriptedTransport::new(vec![forged_outcome]); - - let error = publish_claimed_outbox_event_with_transport( - &outbox, - &store, - &transport, - &publish_claim, - RadrootsOutboxPublishPolicy::new(2_500), - 2_200, - ) - .await - .expect_err("pending receipt rejected"); - assert!(matches!( - error, - RadrootsRelayTransportError::TransportContract(_) - )); +#[test] +fn transport_outcome_wire_rejects_pending_accepted_status() { + let error = serde_json::from_value::<RadrootsTransportOutcome>(serde_json::json!({ + "kind": "Accepted", + "status": "Pending", + "code": null, + "message": null, + })) + .expect_err("pending accepted outcome rejected before transport execution"); + assert!(error.to_string().contains("status")); } #[tokio::test] diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs @@ -275,7 +275,8 @@ impl RadrootsReticulumTransport { .iter() .cloned() .map(|target| RadrootsTransportTargetReceipt::skipped(target, outcome.clone())) - .collect::<Vec<_>>(); + .collect::<Result<Vec<_>, _>>() + .map_err(|_| RadrootsReticulumError::InvalidDeliveryReceipt)?; RadrootsTransportDeliveryReceipt::for_request(&request, target_receipts) .map_err(|_| RadrootsReticulumError::InvalidDeliveryReceipt) } @@ -338,7 +339,7 @@ impl RadrootsTransport for RadrootsReticulumTransport { .iter() .cloned() .map(|target| RadrootsTransportTargetReceipt::skipped(target, outcome.clone())) - .collect::<Vec<_>>(); + .collect::<Result<Vec<_>, _>>()?; Ok(RadrootsTransportFetchReceipt::new( request.request_id, target_receipts, @@ -437,7 +438,7 @@ fn ensure_reticulum_targets( } fn reticulum_outcome(behavior: RadrootsReticulumBehavior) -> RadrootsTransportOutcome { - let mut outcome = match behavior { + let outcome = match behavior { RadrootsReticulumBehavior::RejectDeliveryAttempts => { RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::DeferredUntilImplemented) } @@ -445,23 +446,19 @@ fn reticulum_outcome(behavior: RadrootsReticulumBehavior) -> RadrootsTransportOu RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::DeferredUntilImplemented) } }; - outcome.code = Some( - match behavior { + outcome + .try_with_code(match behavior { RadrootsReticulumBehavior::RejectDeliveryAttempts => UNAVAILABLE_CODE, RadrootsReticulumBehavior::DeferDeliveryPlans => DEFERRED_CODE, - } - .to_owned(), - ); - outcome.message = Some( - match behavior { + }) + .expect("static Reticulum outcome code is bounded") + .try_with_message(match behavior { RadrootsReticulumBehavior::RejectDeliveryAttempts => { RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE } RadrootsReticulumBehavior::DeferDeliveryPlans => DEFERRED_MESSAGE, - } - .to_owned(), - ); - outcome + }) + .expect("static Reticulum outcome message is bounded") } #[cfg(test)] @@ -633,8 +630,8 @@ mod tests { .deliver(delivery_request(vec![reticulum_target()])) .expect("deferred delivery") .target_receipts()[0] - .outcome - .kind, + .outcome() + .kind(), RadrootsTransportOutcomeKind::DeferredUntilImplemented ); @@ -676,8 +673,8 @@ mod tests { RadrootsReticulumBehavior::DeferDeliveryPlans, ] { let outcome = reticulum_outcome(behavior); - assert!(outcome.code.is_some()); - assert!(outcome.message.is_some()); + assert!(outcome.code().is_some()); + assert!(outcome.message().is_some()); } } } diff --git a/crates/transport_reticulum/tests/reticulum.rs b/crates/transport_reticulum/tests/reticulum.rs @@ -293,13 +293,13 @@ fn direct_reticulum_delivery_accepts_any_typed_scope_as_inert_metadata() { assert_eq!(receipt.target_receipts().len(), 1); assert_eq!( receipt.target_receipts()[0] - .target + .target() .scope() .map(|scope| scope.as_str()), Some("farm-north.mesh_1") ); assert_eq!( - receipt.target_receipts()[0].status, + receipt.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); assert_eq!( @@ -318,13 +318,13 @@ fn direct_reticulum_delivery_accepts_any_typed_scope_as_inert_metadata() { .expect("deferred delivery receipt"); assert_eq!( deferred.target_receipts()[0] - .target + .target() .scope() .map(|scope| scope.as_str()), Some("farm-south.mesh_2") ); assert_eq!( - deferred.target_receipts()[0].status, + deferred.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); assert_eq!( @@ -370,7 +370,7 @@ fn core_transport_trait_reports_reticulum_status_delivery_and_fetch() { )) .expect("delivery receipt"); assert_eq!( - delivery.target_receipts()[0].status, + delivery.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); @@ -381,7 +381,7 @@ fn core_transport_trait_reports_reticulum_status_delivery_and_fetch() { .expect("fetch receipt"); assert_eq!(fetch.fetched_count, 0); assert_eq!( - fetch.target_receipts[0].status, + fetch.target_receipts[0].status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); } @@ -399,19 +399,19 @@ fn reject_delivery_attempts_returns_unavailable_without_success_or_nostr_routing ); for target_receipt in receipt.target_receipts() { assert_eq!( - target_receipt.target.kind(), + target_receipt.target().kind(), &RadrootsTransportKind::Reticulum ); assert_eq!( - target_receipt.status, + target_receipt.status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); assert_eq!( - target_receipt.outcome.code.as_deref(), + target_receipt.outcome().code(), Some("transport_unavailable") ); assert_eq!( - target_receipt.outcome.message.as_deref(), + target_receipt.outcome().message(), Some(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE) ); } @@ -451,11 +451,11 @@ fn deferred_delivery_plan_mode_never_counts_as_satisfied() { 0 ); assert_eq!( - receipt.target_receipts()[0].status, + receipt.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); assert_eq!( - receipt.target_receipts()[0].outcome.code.as_deref(), + receipt.target_receipts()[0].outcome().code(), Some("deferred_until_implemented") ); assert!( @@ -512,7 +512,7 @@ fn fetch_reports_deferred_until_implemented_without_observed_events() { assert_eq!(receipt.scope.as_str(), RADROOTS_RETICULUM_SCOPE_ID); assert_eq!(receipt.agent_endpoint, None); assert_eq!( - receipt.outcome.status, + receipt.outcome.status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); assert_eq!( @@ -536,7 +536,7 @@ fn fetch_reports_deferred_until_implemented_without_observed_events() { .fetch(RadrootsReticulumFetchRequest::new("fetch-deferred", 1).expect("fetch")) .expect("fetch receipt"); assert_eq!( - deferred.outcome.status, + deferred.outcome.status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); } @@ -573,7 +573,7 @@ fn configured_agent_endpoint_is_metadata_only_for_status_delivery_and_fetch() { )])) .expect("delivery receipt"); assert_eq!( - receipt.target_receipts()[0].status, + receipt.target_receipts()[0].status(), RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented ); let fetch = transport