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:
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