commit c97e431c09eec9a2e1d964fdb16e66301c06ecab
parent e778aaa15590a7c3c335d3a6756797fc55883cd6
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Jul 2026 09:36:26 +0000
transport: seal bounded public wire models
- freeze exact transport resource maxima in executable vector authority
- seal core status and Reticulum policies behind bounded strict decoders
- bound Nostr outbox policy and receipt surfaces across affected callers
- refresh outbox provenance and cover exact-limit and one-over wire cases
Diffstat:
12 files changed, 2012 insertions(+), 460 deletions(-)
diff --git a/contracts/conformance/vectors/transport/resource_limits.v1.json b/contracts/conformance/vectors/transport/resource_limits.v1.json
@@ -0,0 +1,246 @@
+{
+ "suite": "transport_resource_limits",
+ "contract_version": "1.0.0",
+ "vectors": [
+ {
+ "id": "transport_signed_event_json_max_bytes_001",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 262144,
+ "unit": "bytes"
+ }
+ },
+ {
+ "id": "transport_reticulum_payload_max_bytes_002",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 65536,
+ "unit": "bytes"
+ }
+ },
+ {
+ "id": "transport_opaque_payload_max_bytes_003",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES",
+ "derivation": "RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES"
+ },
+ "expected": {
+ "maximum": 65536,
+ "unit": "bytes"
+ }
+ },
+ {
+ "id": "transport_endpoint_uri_max_bytes_004",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 2048,
+ "unit": "utf8_bytes"
+ }
+ },
+ {
+ "id": "transport_identifier_max_bytes_005",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 256,
+ "unit": "utf8_bytes"
+ }
+ },
+ {
+ "id": "transport_target_scope_max_bytes_006",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES",
+ "derivation": "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES"
+ },
+ "expected": {
+ "maximum": 256,
+ "unit": "utf8_bytes"
+ }
+ },
+ {
+ "id": "transport_target_label_max_bytes_007",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES",
+ "derivation": "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES"
+ },
+ "expected": {
+ "maximum": 256,
+ "unit": "utf8_bytes"
+ }
+ },
+ {
+ "id": "transport_unique_target_max_count_008",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_TARGET_MAX_COUNT",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 16,
+ "unit": "items"
+ }
+ },
+ {
+ "id": "transport_fetch_filter_max_count_009",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 16,
+ "unit": "items"
+ }
+ },
+ {
+ "id": "transport_fetch_filter_max_bytes_010",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 65536,
+ "unit": "compact_json_bytes"
+ }
+ },
+ {
+ "id": "transport_fetch_filters_max_bytes_011",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES",
+ "derivation": "RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT * RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES"
+ },
+ "expected": {
+ "maximum": 1048576,
+ "unit": "compact_json_bytes"
+ }
+ },
+ {
+ "id": "transport_fetch_admitted_event_max_count_012",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 1000,
+ "unit": "items"
+ }
+ },
+ {
+ "id": "transport_fetch_raw_item_max_count_013",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 4096,
+ "unit": "items"
+ }
+ },
+ {
+ "id": "transport_fetch_raw_json_max_bytes_014",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 67108864,
+ "unit": "bytes"
+ }
+ },
+ {
+ "id": "transport_complete_request_diagnostic_max_bytes_015",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 4096,
+ "unit": "utf8_bytes"
+ }
+ },
+ {
+ "id": "transport_outcome_code_max_bytes_016",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES",
+ "derivation": "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES"
+ },
+ "expected": {
+ "maximum": 256,
+ "unit": "utf8_bytes"
+ }
+ },
+ {
+ "id": "transport_outcome_message_max_bytes_017",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES",
+ "derivation": "RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES"
+ },
+ "expected": {
+ "maximum": 4096,
+ "unit": "utf8_bytes"
+ }
+ },
+ {
+ "id": "transport_total_deadline_max_ms_018",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS",
+ "derivation": "resource_authority"
+ },
+ "expected": {
+ "maximum": 30000,
+ "unit": "milliseconds"
+ }
+ },
+ {
+ "id": "transport_delivery_request_id_max_bytes_019",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES",
+ "derivation": "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES"
+ },
+ "expected": {
+ "maximum": 256,
+ "unit": "utf8_bytes"
+ }
+ },
+ {
+ "id": "transport_fetch_request_id_max_bytes_020",
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": "radroots_transport::RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES",
+ "derivation": "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES"
+ },
+ "expected": {
+ "maximum": 256,
+ "unit": "utf8_bytes"
+ }
+ }
+ ]
+}
diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.json b/crates/outbox/contracts/migration_authority_v1.manifest.json
@@ -278,10 +278,10 @@
},
{
"file": {
- "byte_length": 320981,
+ "byte_length": 320734,
"hash_algorithm": "sha256_bytes_v1",
"path": "crates/outbox/src/store.rs",
- "sha256": "6c983dc2854dbb62de093cf748e0b761965f37c32bcf5d2f9bcaee56eb2f2d99"
+ "sha256": "2fbc9690e98bc651953e081a57265bd9778c8dd124c9b26a02668c3bc0f14314"
},
"role": "store_integration"
},
diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.sha256 b/crates/outbox/contracts/migration_authority_v1.manifest.sha256
@@ -1 +1 @@
-4603e33423cfbd589bad220067ec0bf0995fb77c42915a02312bebe4188c82f8
+6ba646dbd786b2b0a5b90adc915c5221c7b8e7c29d2ce99942e39469431f1494
diff --git a/crates/runtime/src/transport.rs b/crates/runtime/src/transport.rs
@@ -734,14 +734,16 @@ mod tests {
fn status<'a>(&'a self) -> RadrootsTransportFuture<'a, RadrootsTransportStatus> {
Box::pin(async move {
- Ok(RadrootsTransportStatus::new(
+ RadrootsTransportStatus::new(
self.kind.clone(),
true,
RadrootsTransportImplementationState::Real,
true,
"ready",
)
- .with_capabilities(RadrootsTransportCapabilities::deliver_and_fetch()))
+ .map(|status| {
+ status.with_capabilities(RadrootsTransportCapabilities::deliver_and_fetch())
+ })
})
}
@@ -820,13 +822,13 @@ mod tests {
fn status<'a>(&'a self) -> RadrootsTransportFuture<'a, RadrootsTransportStatus> {
Box::pin(async {
- Ok(RadrootsTransportStatus::new(
+ RadrootsTransportStatus::new(
RadrootsTransportKind::Nostr,
true,
RadrootsTransportImplementationState::Real,
true,
"forged receipt fixture",
- ))
+ )
})
}
@@ -982,10 +984,10 @@ mod tests {
.await
.expect("receipt");
let status = transport.status().await.expect("status");
- assert_eq!(status.kind, RadrootsTransportKind::Nostr);
+ assert_eq!(status.kind(), &RadrootsTransportKind::Nostr);
assert_eq!(
- status.capabilities,
- RadrootsTransportCapabilities::deliver_and_fetch()
+ status.capabilities(),
+ &RadrootsTransportCapabilities::deliver_and_fetch()
);
let fetch = transport
.fetch(
@@ -1059,19 +1061,19 @@ mod tests {
.expect("receipt");
let status = transport.status().await.expect("status");
assert_eq!(
- status.implementation,
+ status.implementation(),
RadrootsTransportImplementationState::Real
);
assert_eq!(
- status.maturity,
+ status.maturity(),
RadrootsTransportCapabilityMaturity::Preview
);
assert_eq!(
- status.availability,
+ status.availability(),
RadrootsTransportCapabilityAvailability::Unavailable
);
- assert!(!status.capabilities.deliver);
- assert!(!status.capabilities.fetch);
+ assert!(!status.capabilities().can_deliver());
+ assert!(!status.capabilities().can_fetch());
let fetch = transport
.fetch(
RadrootsTransportFetchRequest::new(
diff --git a/crates/transport/src/reticulum.rs b/crates/transport/src/reticulum.rs
@@ -24,14 +24,14 @@ pub enum ReticulumFragmentIntegrityV1 {
PayloadDigest,
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReticulumFragmentPolicyV1 {
- pub mode: ReticulumFragmentationModeV1,
- pub max_fragment_count: u16,
- pub max_reassembled_bytes: usize,
- pub duplicate_fragment_behavior: ReticulumDuplicateFragmentBehaviorV1,
- pub integrity_verification: ReticulumFragmentIntegrityV1,
+ mode: ReticulumFragmentationModeV1,
+ max_fragment_count: u16,
+ max_reassembled_bytes: usize,
+ duplicate_fragment_behavior: ReticulumDuplicateFragmentBehaviorV1,
+ integrity_verification: ReticulumFragmentIntegrityV1,
}
impl ReticulumFragmentPolicyV1 {
@@ -44,13 +44,33 @@ impl ReticulumFragmentPolicyV1 {
integrity_verification: ReticulumFragmentIntegrityV1::PayloadDigest,
}
}
+
+ pub const fn mode(&self) -> ReticulumFragmentationModeV1 {
+ self.mode
+ }
+
+ pub const fn max_fragment_count(&self) -> u16 {
+ self.max_fragment_count
+ }
+
+ pub const fn max_reassembled_bytes(&self) -> usize {
+ self.max_reassembled_bytes
+ }
+
+ pub const fn duplicate_fragment_behavior(&self) -> ReticulumDuplicateFragmentBehaviorV1 {
+ self.duplicate_fragment_behavior
+ }
+
+ pub const fn integrity_verification(&self) -> ReticulumFragmentIntegrityV1 {
+ self.integrity_verification
+ }
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReticulumPayloadPolicyV1 {
- pub max_payload_bytes: usize,
- pub fragment_policy: ReticulumFragmentPolicyV1,
+ max_payload_bytes: usize,
+ fragment_policy: ReticulumFragmentPolicyV1,
}
impl ReticulumPayloadPolicyV1 {
@@ -60,6 +80,14 @@ impl ReticulumPayloadPolicyV1 {
fragment_policy: ReticulumFragmentPolicyV1::unsupported(),
}
}
+
+ pub const fn max_payload_bytes(&self) -> usize {
+ self.max_payload_bytes
+ }
+
+ pub const fn fragment_policy(&self) -> &ReticulumFragmentPolicyV1 {
+ &self.fragment_policy
+ }
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -78,9 +106,9 @@ pub enum ReticulumPrivacySemanticsV1 {
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReticulumRoutingMetadataV1 {
- pub scope: RadrootsTransportMeshScopeId,
- pub gateway: ReticulumGatewaySemanticsV1,
- pub privacy: ReticulumPrivacySemanticsV1,
+ scope: RadrootsTransportMeshScopeId,
+ gateway: ReticulumGatewaySemanticsV1,
+ privacy: ReticulumPrivacySemanticsV1,
}
impl ReticulumRoutingMetadataV1 {
@@ -91,6 +119,18 @@ impl ReticulumRoutingMetadataV1 {
privacy: ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly,
}
}
+
+ pub const fn scope(&self) -> &RadrootsTransportMeshScopeId {
+ &self.scope
+ }
+
+ pub const fn gateway(&self) -> ReticulumGatewaySemanticsV1 {
+ self.gateway
+ }
+
+ pub const fn privacy(&self) -> ReticulumPrivacySemanticsV1 {
+ self.privacy
+ }
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
@@ -213,32 +253,171 @@ impl<'de> serde::Deserialize<'de> for ReticulumDestinationV1 {
}
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReticulumCapabilityReportV1 {
- pub delivery_required: bool,
- pub fetch_required: bool,
- pub can_deliver: bool,
- pub can_fetch: bool,
- pub can_discover: bool,
- pub can_forward_gateway: bool,
- pub can_observe_receipts: bool,
- pub destination: ReticulumDestinationV1,
- pub payload_policy: ReticulumPayloadPolicyV1,
+ delivery_required: bool,
+ fetch_required: bool,
+ can_deliver: bool,
+ can_fetch: bool,
+ can_discover: bool,
+ can_forward_gateway: bool,
+ can_observe_receipts: bool,
+ destination: ReticulumDestinationV1,
+ payload_policy: ReticulumPayloadPolicyV1,
}
impl ReticulumCapabilityReportV1 {
pub fn unavailable_local() -> Self {
+ Self::unavailable(ReticulumDestinationV1::local(), true)
+ }
+
+ pub fn unavailable(destination: ReticulumDestinationV1, delivery_required: bool) -> Self {
Self {
- delivery_required: true,
+ delivery_required,
fetch_required: false,
can_deliver: false,
can_fetch: false,
can_discover: false,
can_forward_gateway: false,
can_observe_receipts: false,
- destination: ReticulumDestinationV1::local(),
+ destination,
payload_policy: ReticulumPayloadPolicyV1::v1(),
}
}
+
+ pub const fn is_delivery_required(&self) -> bool {
+ self.delivery_required
+ }
+
+ pub const fn is_fetch_required(&self) -> bool {
+ self.fetch_required
+ }
+
+ pub const fn can_deliver(&self) -> bool {
+ self.can_deliver
+ }
+
+ pub const fn can_fetch(&self) -> bool {
+ self.can_fetch
+ }
+
+ pub const fn can_discover(&self) -> bool {
+ self.can_discover
+ }
+
+ pub const fn can_forward_gateway(&self) -> bool {
+ self.can_forward_gateway
+ }
+
+ pub const fn can_observe_receipts(&self) -> bool {
+ self.can_observe_receipts
+ }
+
+ pub const fn destination(&self) -> &ReticulumDestinationV1 {
+ &self.destination
+ }
+
+ pub const fn payload_policy(&self) -> &ReticulumPayloadPolicyV1 {
+ &self.payload_policy
+ }
+}
+
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReticulumFragmentPolicyV1Wire {
+ mode: ReticulumFragmentationModeV1,
+ max_fragment_count: u16,
+ max_reassembled_bytes: usize,
+ duplicate_fragment_behavior: ReticulumDuplicateFragmentBehaviorV1,
+ integrity_verification: ReticulumFragmentIntegrityV1,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for ReticulumFragmentPolicyV1 {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = ReticulumFragmentPolicyV1Wire::deserialize(deserializer)?;
+ let policy = Self::unsupported();
+ if wire.mode != policy.mode
+ || wire.max_fragment_count != policy.max_fragment_count
+ || wire.max_reassembled_bytes != policy.max_reassembled_bytes
+ || wire.duplicate_fragment_behavior != policy.duplicate_fragment_behavior
+ || wire.integrity_verification != policy.integrity_verification
+ {
+ return Err(serde::de::Error::custom(
+ "Reticulum fragment policy must match the fixed v1 authority",
+ ));
+ }
+ Ok(policy)
+ }
+}
+
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReticulumPayloadPolicyV1Wire {
+ max_payload_bytes: usize,
+ fragment_policy: ReticulumFragmentPolicyV1,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for ReticulumPayloadPolicyV1 {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = ReticulumPayloadPolicyV1Wire::deserialize(deserializer)?;
+ let policy = Self::v1();
+ if wire.max_payload_bytes != policy.max_payload_bytes
+ || wire.fragment_policy != policy.fragment_policy
+ {
+ return Err(serde::de::Error::custom(
+ "Reticulum payload policy must match the fixed v1 authority",
+ ));
+ }
+ Ok(policy)
+ }
+}
+
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReticulumCapabilityReportV1Wire {
+ delivery_required: bool,
+ fetch_required: bool,
+ can_deliver: bool,
+ can_fetch: bool,
+ can_discover: bool,
+ can_forward_gateway: bool,
+ can_observe_receipts: bool,
+ destination: ReticulumDestinationV1,
+ payload_policy: ReticulumPayloadPolicyV1,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for ReticulumCapabilityReportV1 {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = ReticulumCapabilityReportV1Wire::deserialize(deserializer)?;
+ let report = Self::unavailable(wire.destination, wire.delivery_required);
+ if wire.fetch_required != report.fetch_required
+ || wire.can_deliver != report.can_deliver
+ || wire.can_fetch != report.can_fetch
+ || wire.can_discover != report.can_discover
+ || wire.can_forward_gateway != report.can_forward_gateway
+ || wire.can_observe_receipts != report.can_observe_receipts
+ || wire.payload_policy != report.payload_policy
+ {
+ return Err(serde::de::Error::custom(
+ "Reticulum capability report must match the unavailable v1 authority",
+ ));
+ }
+ Ok(report)
+ }
}
diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs
@@ -281,13 +281,14 @@ impl<'de> serde::Deserialize<'de> for RadrootsTransportOutcome {
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsTransportCapabilities {
- pub deliver: bool,
- pub fetch: bool,
- pub discovery: bool,
- pub gateway_forwarding: bool,
- pub receipt_observation: bool,
+ deliver: bool,
+ fetch: bool,
+ discovery: bool,
+ gateway_forwarding: bool,
+ receipt_observation: bool,
}
impl RadrootsTransportCapabilities {
@@ -355,22 +356,42 @@ impl RadrootsTransportCapabilities {
receipt_observation: false,
}
}
+
+ pub const fn can_deliver(&self) -> bool {
+ self.deliver
+ }
+
+ pub const fn can_fetch(&self) -> bool {
+ self.fetch
+ }
+
+ pub const fn can_discover(&self) -> bool {
+ self.discovery
+ }
+
+ pub const fn can_forward_gateway(&self) -> bool {
+ self.gateway_forwarding
+ }
+
+ pub const fn can_observe_receipts(&self) -> bool {
+ self.receipt_observation
+ }
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsTransportStatus {
#[cfg_attr(feature = "serde", serde(rename = "transport"))]
- pub kind: RadrootsTransportKind,
- pub profile_id: Option<String>,
- pub endpoint_uri: Option<String>,
- pub configured: bool,
- pub implementation: RadrootsTransportImplementationState,
- pub maturity: RadrootsTransportCapabilityMaturity,
- pub availability: RadrootsTransportCapabilityAvailability,
- pub usable_for_delivery: bool,
- pub capabilities: RadrootsTransportCapabilities,
- pub message: String,
+ kind: RadrootsTransportKind,
+ profile_id: Option<String>,
+ endpoint_uri: Option<String>,
+ configured: bool,
+ implementation: RadrootsTransportImplementationState,
+ maturity: RadrootsTransportCapabilityMaturity,
+ availability: RadrootsTransportCapabilityAvailability,
+ usable_for_delivery: bool,
+ capabilities: RadrootsTransportCapabilities,
+ message: String,
}
impl RadrootsTransportStatus {
@@ -380,8 +401,14 @@ impl RadrootsTransportStatus {
implementation: RadrootsTransportImplementationState,
usable_for_delivery: bool,
message: impl Into<String>,
- ) -> Self {
- Self {
+ ) -> Result<Self, crate::RadrootsTransportError> {
+ let message = message.into();
+ crate::limits::ensure_resource_limit(
+ "transport_status_message",
+ message.len(),
+ crate::RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES,
+ )?;
+ Ok(Self {
kind,
profile_id: None,
endpoint_uri: None,
@@ -399,8 +426,8 @@ impl RadrootsTransportStatus {
} else {
RadrootsTransportCapabilities::none()
},
- message: message.into(),
- }
+ message,
+ })
}
pub fn with_capabilities(mut self, capabilities: RadrootsTransportCapabilities) -> Self {
@@ -421,13 +448,159 @@ impl RadrootsTransportStatus {
self
}
- pub fn with_profile_id(mut self, profile_id: impl Into<String>) -> Self {
- self.profile_id = Some(profile_id.into());
- self
+ pub fn try_with_profile_id(
+ mut self,
+ profile_id: impl Into<String>,
+ ) -> Result<Self, crate::RadrootsTransportError> {
+ let profile_id = profile_id.into();
+ crate::limits::ensure_resource_limit(
+ "transport_status_profile_id",
+ profile_id.len(),
+ crate::RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES,
+ )?;
+ self.profile_id = Some(profile_id);
+ Ok(self)
}
- pub fn with_endpoint_uri(mut self, endpoint_uri: impl Into<String>) -> Self {
- self.endpoint_uri = Some(endpoint_uri.into());
- self
+ pub fn try_with_endpoint_uri(
+ mut self,
+ endpoint_uri: impl Into<String>,
+ ) -> Result<Self, crate::RadrootsTransportError> {
+ let endpoint_uri = endpoint_uri.into();
+ crate::limits::ensure_resource_limit(
+ "transport_status_endpoint_uri",
+ endpoint_uri.len(),
+ crate::RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES,
+ )?;
+ self.endpoint_uri = Some(endpoint_uri);
+ Ok(self)
+ }
+
+ pub const fn kind(&self) -> &RadrootsTransportKind {
+ &self.kind
+ }
+
+ pub fn profile_id(&self) -> Option<&str> {
+ self.profile_id.as_deref()
+ }
+
+ pub fn endpoint_uri(&self) -> Option<&str> {
+ self.endpoint_uri.as_deref()
+ }
+
+ pub const fn is_configured(&self) -> bool {
+ self.configured
+ }
+
+ pub const fn implementation(&self) -> RadrootsTransportImplementationState {
+ self.implementation
+ }
+
+ pub const fn maturity(&self) -> RadrootsTransportCapabilityMaturity {
+ self.maturity
+ }
+
+ pub const fn availability(&self) -> RadrootsTransportCapabilityAvailability {
+ self.availability
+ }
+
+ pub const fn is_usable_for_delivery(&self) -> bool {
+ self.usable_for_delivery
+ }
+
+ pub const fn capabilities(&self) -> &RadrootsTransportCapabilities {
+ &self.capabilities
+ }
+
+ pub fn message(&self) -> &str {
+ self.message.as_str()
+ }
+}
+
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RadrootsTransportStatusWire {
+ #[serde(rename = "transport")]
+ kind: RadrootsTransportKind,
+ #[serde(deserialize_with = "deserialize_status_profile_id")]
+ profile_id: Option<String>,
+ #[serde(deserialize_with = "deserialize_status_endpoint_uri")]
+ endpoint_uri: Option<String>,
+ configured: bool,
+ implementation: RadrootsTransportImplementationState,
+ maturity: RadrootsTransportCapabilityMaturity,
+ availability: RadrootsTransportCapabilityAvailability,
+ usable_for_delivery: bool,
+ capabilities: RadrootsTransportCapabilities,
+ #[serde(deserialize_with = "deserialize_status_message")]
+ message: String,
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_status_profile_id<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ crate::serde_bounds::deserialize_option_string(
+ deserializer,
+ "transport_status_profile_id",
+ crate::RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES,
+ )
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_status_endpoint_uri<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ crate::serde_bounds::deserialize_option_string(
+ deserializer,
+ "transport_status_endpoint_uri",
+ crate::RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES,
+ )
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_status_message<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ crate::serde_bounds::deserialize_string(
+ deserializer,
+ "transport_status_message",
+ crate::RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES,
+ )
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for RadrootsTransportStatus {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = RadrootsTransportStatusWire::deserialize(deserializer)?;
+ let status = Self::new(
+ wire.kind,
+ wire.configured,
+ wire.implementation,
+ wire.usable_for_delivery,
+ wire.message,
+ )
+ .map_err(serde::de::Error::custom)?
+ .with_maturity(wire.maturity)
+ .with_availability(wire.availability)
+ .with_capabilities(wire.capabilities);
+ let status = match wire.profile_id {
+ Some(profile_id) => status
+ .try_with_profile_id(profile_id)
+ .map_err(serde::de::Error::custom)?,
+ None => status,
+ };
+ match wire.endpoint_uri {
+ Some(endpoint_uri) => status.try_with_endpoint_uri(endpoint_uri),
+ None => Ok(status),
+ }
+ .map_err(serde::de::Error::custom)
}
}
diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs
@@ -4,11 +4,14 @@ use radroots_transport::{
RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID,
RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES, RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES,
RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT,
- RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES, RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES,
- RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES, RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES,
- RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES, RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES,
- RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES,
- RADROOTS_TRANSPORT_TARGET_MAX_COUNT, RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES,
+ RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES, 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_FETCH_REQUEST_ID_MAX_BYTES,
+ RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES,
+ RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES, RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES,
+ RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES, RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES,
+ RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT,
+ RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES, RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS,
RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability,
RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt,
RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus,
@@ -68,15 +71,15 @@ fn reticulum_destination_v1_is_canonical_and_stable() {
assert_eq!(destination, local);
assert_eq!(destination.uri().as_str(), RADROOTS_RETICULUM_ENDPOINT_URI);
assert_eq!(
- destination.routing().scope.as_str(),
+ destination.routing().scope().as_str(),
RADROOTS_RETICULUM_SCOPE_ID
);
assert_eq!(
- destination.routing().gateway,
+ destination.routing().gateway(),
ReticulumGatewaySemanticsV1::NoGatewayForwarding
);
assert_eq!(
- destination.routing().privacy,
+ destination.routing().privacy(),
ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly
);
assert_eq!(destination.fingerprint(), target.fingerprint());
@@ -147,36 +150,92 @@ fn reticulum_destination_deserialization_revalidates_canonical_identity() {
fn reticulum_capability_report_v1_is_explicitly_unavailable_without_fragmentation() {
let report = ReticulumCapabilityReportV1::unavailable_local();
- assert!(report.delivery_required);
- assert!(!report.fetch_required);
- assert!(!report.can_deliver);
- assert!(!report.can_fetch);
- assert!(!report.can_discover);
- assert!(!report.can_forward_gateway);
- assert!(!report.can_observe_receipts);
+ assert!(report.is_delivery_required());
+ assert!(!report.is_fetch_required());
+ assert!(!report.can_deliver());
+ assert!(!report.can_fetch());
+ assert!(!report.can_discover());
+ assert!(!report.can_forward_gateway());
+ assert!(!report.can_observe_receipts());
assert_eq!(
- report.payload_policy.fragment_policy.mode,
+ report.payload_policy().fragment_policy().mode(),
ReticulumFragmentationModeV1::Unsupported
);
- assert_eq!(report.payload_policy.fragment_policy.max_fragment_count, 1);
assert_eq!(
- report.payload_policy.fragment_policy.max_reassembled_bytes,
- report.payload_policy.max_payload_bytes
+ report
+ .payload_policy()
+ .fragment_policy()
+ .max_fragment_count(),
+ 1
+ );
+ assert_eq!(
+ report
+ .payload_policy()
+ .fragment_policy()
+ .max_reassembled_bytes(),
+ report.payload_policy().max_payload_bytes()
);
assert_eq!(
report
- .payload_policy
- .fragment_policy
- .duplicate_fragment_behavior,
+ .payload_policy()
+ .fragment_policy()
+ .duplicate_fragment_behavior(),
ReticulumDuplicateFragmentBehaviorV1::Reject
);
assert_eq!(
- report.payload_policy.fragment_policy.integrity_verification,
+ report
+ .payload_policy()
+ .fragment_policy()
+ .integrity_verification(),
ReticulumFragmentIntegrityV1::PayloadDigest
);
}
#[test]
+#[cfg(feature = "serde")]
+fn transport_bounds_reticulum_policy_wire_rejects_forged_or_unknown_state() {
+ let report = ReticulumCapabilityReportV1::unavailable_local();
+ let canonical = serde_json::to_value(&report).expect("serialize capability report");
+ assert_eq!(
+ serde_json::from_value::<ReticulumCapabilityReportV1>(canonical.clone())
+ .expect("reload capability report"),
+ report
+ );
+
+ for pointer in [
+ "/fetch_required",
+ "/can_deliver",
+ "/can_fetch",
+ "/can_discover",
+ "/can_forward_gateway",
+ "/can_observe_receipts",
+ ] {
+ let mut forged = canonical.clone();
+ *forged.pointer_mut(pointer).expect("capability field") = Value::Bool(true);
+ assert!(serde_json::from_value::<ReticulumCapabilityReportV1>(forged).is_err());
+ }
+ for (pointer, value) in [
+ ("/payload_policy/max_payload_bytes", Value::from(65_535)),
+ (
+ "/payload_policy/fragment_policy/max_fragment_count",
+ Value::from(2),
+ ),
+ (
+ "/payload_policy/fragment_policy/max_reassembled_bytes",
+ Value::from(65_535),
+ ),
+ ] {
+ let mut forged = canonical.clone();
+ *forged.pointer_mut(pointer).expect("policy field") = value;
+ assert!(serde_json::from_value::<ReticulumCapabilityReportV1>(forged).is_err());
+ }
+
+ let mut unknown = canonical;
+ unknown["unexpected"] = Value::Bool(true);
+ assert!(serde_json::from_value::<ReticulumCapabilityReportV1>(unknown).is_err());
+}
+
+#[test]
fn transport_kind_parser_round_trips_first_wave_canonical_labels() {
assert_eq!(
RadrootsTransportKind::parse(" NOSTR ").expect("nostr kind"),
@@ -422,26 +481,26 @@ fn transport_status_models_canonical_configuration_and_delivery_usability() {
true,
"ready",
)
- .with_profile_id("transport.nostr.default")
- .with_endpoint_uri("wss://relay.example");
-
- assert_eq!(status.kind, RadrootsTransportKind::Nostr);
- assert_eq!(
- status.profile_id.as_deref(),
- Some("transport.nostr.default")
- );
- assert_eq!(status.endpoint_uri.as_deref(), Some("wss://relay.example"));
- assert!(status.configured);
- assert_eq!(
- status.implementation,
+ .expect("bounded status")
+ .try_with_profile_id("transport.nostr.default")
+ .expect("bounded profile id")
+ .try_with_endpoint_uri("wss://relay.example")
+ .expect("bounded endpoint URI");
+
+ assert_eq!(status.kind(), &RadrootsTransportKind::Nostr);
+ assert_eq!(status.profile_id(), Some("transport.nostr.default"));
+ assert_eq!(status.endpoint_uri(), Some("wss://relay.example"));
+ assert!(status.is_configured());
+ assert_eq!(
+ status.implementation(),
RadrootsTransportImplementationState::Real
);
- assert!(status.usable_for_delivery);
+ assert!(status.is_usable_for_delivery());
assert_eq!(
- status.capabilities,
- RadrootsTransportCapabilities::deliver_only()
+ status.capabilities(),
+ &RadrootsTransportCapabilities::deliver_only()
);
- assert_eq!(status.message, "ready");
+ assert_eq!(status.message(), "ready");
let json = serde_json::to_value(&status).expect("status json");
assert_eq!(json["transport"], "nostr");
@@ -806,6 +865,183 @@ fn checked_in_transport_target_uri_vectors_match_parser_behavior() {
}
#[test]
+fn transport_bounds_checked_in_resource_manifest_matches_exported_constants() {
+ let vectors =
+ include_str!("../../../contracts/conformance/vectors/transport/resource_limits.v1.json");
+ let document: Value = serde_json::from_str(vectors).expect("transport resource manifest json");
+ let entries = document
+ .get("vectors")
+ .and_then(Value::as_array)
+ .expect("transport resource vectors");
+ let expected = [
+ (
+ "transport_signed_event_json_max_bytes_001",
+ "radroots_transport::RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES",
+ "resource_authority",
+ RADROOTS_TRANSPORT_SIGNED_EVENT_JSON_MAX_BYTES as u64,
+ "bytes",
+ ),
+ (
+ "transport_reticulum_payload_max_bytes_002",
+ "radroots_transport::RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES",
+ "resource_authority",
+ RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES as u64,
+ "bytes",
+ ),
+ (
+ "transport_opaque_payload_max_bytes_003",
+ "radroots_transport::RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES",
+ "RADROOTS_TRANSPORT_RETICULUM_PAYLOAD_MAX_BYTES",
+ RADROOTS_TRANSPORT_OPAQUE_PAYLOAD_MAX_BYTES as u64,
+ "bytes",
+ ),
+ (
+ "transport_endpoint_uri_max_bytes_004",
+ "radroots_transport::RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES",
+ "resource_authority",
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES as u64,
+ "utf8_bytes",
+ ),
+ (
+ "transport_identifier_max_bytes_005",
+ "radroots_transport::RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES",
+ "resource_authority",
+ RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES as u64,
+ "utf8_bytes",
+ ),
+ (
+ "transport_target_scope_max_bytes_006",
+ "radroots_transport::RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES",
+ "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES",
+ RADROOTS_TRANSPORT_TARGET_SCOPE_MAX_BYTES as u64,
+ "utf8_bytes",
+ ),
+ (
+ "transport_target_label_max_bytes_007",
+ "radroots_transport::RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES",
+ "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES",
+ RADROOTS_TRANSPORT_TARGET_LABEL_MAX_BYTES as u64,
+ "utf8_bytes",
+ ),
+ (
+ "transport_unique_target_max_count_008",
+ "radroots_transport::RADROOTS_TRANSPORT_TARGET_MAX_COUNT",
+ "resource_authority",
+ RADROOTS_TRANSPORT_TARGET_MAX_COUNT as u64,
+ "items",
+ ),
+ (
+ "transport_fetch_filter_max_count_009",
+ "radroots_transport::RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT",
+ "resource_authority",
+ RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT as u64,
+ "items",
+ ),
+ (
+ "transport_fetch_filter_max_bytes_010",
+ "radroots_transport::RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES",
+ "resource_authority",
+ RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES as u64,
+ "compact_json_bytes",
+ ),
+ (
+ "transport_fetch_filters_max_bytes_011",
+ "radroots_transport::RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES",
+ "RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT * RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES",
+ RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES as u64,
+ "compact_json_bytes",
+ ),
+ (
+ "transport_fetch_admitted_event_max_count_012",
+ "radroots_transport::RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT",
+ "resource_authority",
+ RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT as u64,
+ "items",
+ ),
+ (
+ "transport_fetch_raw_item_max_count_013",
+ "radroots_transport::RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT",
+ "resource_authority",
+ RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT as u64,
+ "items",
+ ),
+ (
+ "transport_fetch_raw_json_max_bytes_014",
+ "radroots_transport::RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES",
+ "resource_authority",
+ RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES as u64,
+ "bytes",
+ ),
+ (
+ "transport_complete_request_diagnostic_max_bytes_015",
+ "radroots_transport::RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES",
+ "resource_authority",
+ RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES as u64,
+ "utf8_bytes",
+ ),
+ (
+ "transport_outcome_code_max_bytes_016",
+ "radroots_transport::RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES",
+ "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES",
+ RADROOTS_TRANSPORT_OUTCOME_CODE_MAX_BYTES as u64,
+ "utf8_bytes",
+ ),
+ (
+ "transport_outcome_message_max_bytes_017",
+ "radroots_transport::RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES",
+ "RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES",
+ RADROOTS_TRANSPORT_OUTCOME_MESSAGE_MAX_BYTES as u64,
+ "utf8_bytes",
+ ),
+ (
+ "transport_total_deadline_max_ms_018",
+ "radroots_transport::RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS",
+ "resource_authority",
+ RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS,
+ "milliseconds",
+ ),
+ (
+ "transport_delivery_request_id_max_bytes_019",
+ "radroots_transport::RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES",
+ "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES",
+ RADROOTS_TRANSPORT_DELIVERY_REQUEST_ID_MAX_BYTES as u64,
+ "utf8_bytes",
+ ),
+ (
+ "transport_fetch_request_id_max_bytes_020",
+ "radroots_transport::RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES",
+ "RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES",
+ RADROOTS_TRANSPORT_FETCH_REQUEST_ID_MAX_BYTES as u64,
+ "utf8_bytes",
+ ),
+ ];
+
+ assert_eq!(entries.len(), expected.len());
+ for (entry, (id, authority, derivation, maximum, unit)) in entries.iter().zip(expected) {
+ assert_eq!(
+ entry,
+ &serde_json::json!({
+ "id": id,
+ "kind": "transport.resource_limit.exact",
+ "input": {
+ "authority": authority,
+ "derivation": derivation,
+ },
+ "expected": {
+ "maximum": maximum,
+ "unit": unit,
+ },
+ })
+ );
+ }
+
+ assert_eq!(
+ RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES,
+ RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT * RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES
+ );
+}
+
+#[test]
fn reticulum_transport_targets_use_default_destination_and_scope() {
let target = RadrootsTransportTarget::reticulum().expect("Reticulum target");
assert_eq!(target.uri().as_str(), RADROOTS_RETICULUM_ENDPOINT_URI);
@@ -1283,14 +1519,16 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() {
fn status<'a>(&'a self) -> RadrootsTransportFuture<'a, RadrootsTransportStatus> {
Box::pin(async move {
- Ok(RadrootsTransportStatus::new(
+ RadrootsTransportStatus::new(
RadrootsTransportKind::Local,
true,
RadrootsTransportImplementationState::Real,
true,
"ready",
)
- .with_capabilities(RadrootsTransportCapabilities::deliver_and_fetch()))
+ .map(|status| {
+ status.with_capabilities(RadrootsTransportCapabilities::deliver_and_fetch())
+ })
})
}
@@ -1332,10 +1570,10 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() {
let transport = MemoryTransport { target };
assert_eq!(transport.transport_kind(), RadrootsTransportKind::Local);
let status = futures::executor::block_on(transport.status()).expect("status");
- assert_eq!(status.kind, RadrootsTransportKind::Local);
+ assert_eq!(status.kind(), &RadrootsTransportKind::Local);
assert_eq!(
- status.capabilities,
- RadrootsTransportCapabilities::deliver_and_fetch()
+ status.capabilities(),
+ &RadrootsTransportCapabilities::deliver_and_fetch()
);
let delivery = futures::executor::block_on(
transport.deliver(
@@ -1910,8 +2148,8 @@ fn status_contract_covers_builders_and_availability_defaults() {
Some("accepted")
);
- assert!(!RadrootsTransportCapabilities::none().deliver);
- assert!(RadrootsTransportCapabilities::fetch_only().fetch);
+ assert!(!RadrootsTransportCapabilities::none().can_deliver());
+ assert!(RadrootsTransportCapabilities::fetch_only().can_fetch());
assert_eq!(
RadrootsTransportCapabilities::reticulum_unavailable(),
RadrootsTransportCapabilities::none()
@@ -1920,11 +2158,11 @@ fn status_contract_covers_builders_and_availability_defaults() {
.with_discovery(true)
.with_gateway_forwarding(true)
.with_receipt_observation(true);
- assert!(capabilities.deliver);
- assert!(capabilities.fetch);
- assert!(capabilities.discovery);
- assert!(capabilities.gateway_forwarding);
- assert!(capabilities.receipt_observation);
+ assert!(capabilities.can_deliver());
+ assert!(capabilities.can_fetch());
+ assert!(capabilities.can_discover());
+ assert!(capabilities.can_forward_gateway());
+ assert!(capabilities.can_observe_receipts());
let unavailable = RadrootsTransportStatus::new(
RadrootsTransportKind::Reticulum,
@@ -1933,21 +2171,24 @@ fn status_contract_covers_builders_and_availability_defaults() {
false,
"unavailable",
)
+ .expect("bounded status")
.with_capabilities(capabilities.clone())
.with_maturity(RadrootsTransportCapabilityMaturity::Preview)
.with_availability(RadrootsTransportCapabilityAvailability::Degraded)
- .with_profile_id("reticulum.local")
- .with_endpoint_uri(RADROOTS_RETICULUM_ENDPOINT_URI);
+ .try_with_profile_id("reticulum.local")
+ .expect("bounded profile id")
+ .try_with_endpoint_uri(RADROOTS_RETICULUM_ENDPOINT_URI)
+ .expect("bounded endpoint URI");
assert_eq!(
- unavailable.availability,
+ unavailable.availability(),
RadrootsTransportCapabilityAvailability::Degraded
);
assert_eq!(
- unavailable.maturity,
+ unavailable.maturity(),
RadrootsTransportCapabilityMaturity::Preview
);
- assert_eq!(unavailable.capabilities, capabilities);
- assert!(!unavailable.usable_for_delivery);
+ assert_eq!(unavailable.capabilities(), &capabilities);
+ assert!(!unavailable.is_usable_for_delivery());
assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_ready_for_attempt());
assert!(!RadrootsTransportDeliveryTargetStatus::Accepted.is_retryable_failure());
@@ -1958,6 +2199,103 @@ fn status_contract_covers_builders_and_availability_defaults() {
#[test]
#[cfg(feature = "serde")]
+fn transport_bounds_status_construction_and_wire_are_strict() {
+ let exact_message = "m".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES);
+ let exact_profile = "p".repeat(RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES);
+ let exact_endpoint = "e".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES);
+ let status = RadrootsTransportStatus::new(
+ RadrootsTransportKind::Local,
+ true,
+ RadrootsTransportImplementationState::Real,
+ true,
+ exact_message,
+ )
+ .expect("exact status message")
+ .try_with_profile_id(exact_profile)
+ .expect("exact profile id")
+ .try_with_endpoint_uri(exact_endpoint)
+ .expect("exact endpoint URI");
+ let wire = serde_json::to_value(&status).expect("serialize bounded status");
+ assert_eq!(
+ serde_json::from_value::<RadrootsTransportStatus>(wire.clone())
+ .expect("reload bounded status"),
+ status
+ );
+
+ assert_eq!(
+ RadrootsTransportStatus::new(
+ RadrootsTransportKind::Local,
+ true,
+ RadrootsTransportImplementationState::Real,
+ true,
+ "m".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1),
+ )
+ .expect_err("one-over status message"),
+ RadrootsTransportError::ResourceLimitExceeded {
+ field: "transport_status_message",
+ max: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES,
+ actual: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1,
+ }
+ );
+ assert_eq!(
+ RadrootsTransportStatus::new(
+ RadrootsTransportKind::Local,
+ true,
+ RadrootsTransportImplementationState::Real,
+ true,
+ "ready",
+ )
+ .expect("status")
+ .try_with_profile_id("p".repeat(RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES + 1))
+ .expect_err("one-over profile id"),
+ RadrootsTransportError::ResourceLimitExceeded {
+ field: "transport_status_profile_id",
+ max: RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES,
+ actual: RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES + 1,
+ }
+ );
+ assert_eq!(
+ RadrootsTransportStatus::new(
+ RadrootsTransportKind::Local,
+ true,
+ RadrootsTransportImplementationState::Real,
+ true,
+ "ready",
+ )
+ .expect("status")
+ .try_with_endpoint_uri("e".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES + 1))
+ .expect_err("one-over endpoint URI"),
+ RadrootsTransportError::ResourceLimitExceeded {
+ field: "transport_status_endpoint_uri",
+ max: RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES,
+ actual: RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES + 1,
+ }
+ );
+
+ for field in ["message", "profile_id", "endpoint_uri"] {
+ let mut oversized = wire.clone();
+ let max = match field {
+ "message" => RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES,
+ "profile_id" => RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES,
+ "endpoint_uri" => RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES,
+ _ => unreachable!(),
+ };
+ oversized[field] = Value::String("x".repeat(max + 1));
+ assert!(serde_json::from_value::<RadrootsTransportStatus>(oversized).is_err());
+ }
+ let mut unknown = wire;
+ unknown["unexpected"] = Value::Bool(true);
+ assert!(serde_json::from_value::<RadrootsTransportStatus>(unknown).is_err());
+
+ let capabilities = serde_json::to_value(RadrootsTransportCapabilities::none())
+ .expect("serialize capabilities");
+ let mut unknown_capability = capabilities;
+ unknown_capability["unexpected"] = Value::Bool(true);
+ assert!(serde_json::from_value::<RadrootsTransportCapabilities>(unknown_capability).is_err());
+}
+
+#[test]
+#[cfg(feature = "serde")]
fn transport_kind_deserializer_rejects_non_string_values() {
assert!(serde_json::from_str::<RadrootsTransportKind>("1").is_err());
assert!(serde_json::from_str::<RadrootsTransportKind>("\"NOSTR\"").is_err());
diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs
@@ -27,18 +27,19 @@ use radroots_transport::{
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOutboxPublishPolicy {
- pub next_attempt_after_ms: i64,
- pub republish_accepted_relays: bool,
- pub relay_url_policy: RadrootsRelayUrlPolicy,
+ next_attempt_after_ms: i64,
+ republish_accepted_relays: bool,
+ relay_url_policy: RadrootsRelayUrlPolicy,
}
impl RadrootsOutboxPublishPolicy {
- pub fn new(next_attempt_after_ms: i64) -> Self {
- Self {
+ pub fn new(next_attempt_after_ms: i64) -> Result<Self, RadrootsRelayTransportError> {
+ ensure_nonnegative_timestamp("next_attempt_after_ms", next_attempt_after_ms)?;
+ Ok(Self {
next_attempt_after_ms,
republish_accepted_relays: false,
relay_url_policy: RadrootsRelayUrlPolicy::Public,
- }
+ })
}
pub fn republish_accepted_relays(mut self, enabled: bool) -> Self {
@@ -46,36 +47,124 @@ impl RadrootsOutboxPublishPolicy {
self
}
- pub fn relay_url_policy(mut self, policy: RadrootsRelayUrlPolicy) -> Self {
+ pub fn with_relay_url_policy(mut self, policy: RadrootsRelayUrlPolicy) -> Self {
self.relay_url_policy = policy;
self
}
+
+ pub const fn next_attempt_after_ms(&self) -> i64 {
+ self.next_attempt_after_ms
+ }
+
+ pub const fn should_republish_accepted_relays(&self) -> bool {
+ self.republish_accepted_relays
+ }
+
+ pub const fn relay_url_policy(&self) -> RadrootsRelayUrlPolicy {
+ self.relay_url_policy
+ }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOutboxPublishReceipt {
- pub local_ingest: RadrootsOutboxEventStoreIngestReceipt,
- pub event_id: String,
- pub attempted_count: usize,
- pub accepted_count: usize,
- pub retryable_count: usize,
- pub terminal_count: usize,
- pub quorum: usize,
- pub quorum_met: bool,
- pub target_receipts: Vec<RadrootsOutboxPublishTargetReceipt>,
- pub relay_receipts: Vec<RadrootsRelayPublishRelayReceipt>,
+ local_ingest: RadrootsOutboxEventStoreIngestReceipt,
+ event_id: String,
+ attempted_count: usize,
+ accepted_count: usize,
+ retryable_count: usize,
+ terminal_count: usize,
+ quorum: usize,
+ quorum_met: bool,
+ target_receipts: Vec<RadrootsOutboxPublishTargetReceipt>,
+ relay_receipts: Vec<RadrootsRelayPublishRelayReceipt>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsOutboxPublishTargetReceipt {
- pub delivery_target_id: i64,
- pub endpoint_uri: String,
- pub endpoint_fingerprint: RadrootsTransportTargetFingerprint,
- pub target_scope: Option<String>,
- pub target_label: Option<String>,
- pub attempted: bool,
- pub transport_status: RadrootsTransportDeliveryTargetStatus,
- pub outcome: RadrootsRelayOutcome,
+ delivery_target_id: i64,
+ endpoint_uri: String,
+ endpoint_fingerprint: RadrootsTransportTargetFingerprint,
+ target_scope: Option<String>,
+ target_label: Option<String>,
+ attempted: bool,
+ transport_status: RadrootsTransportDeliveryTargetStatus,
+ outcome: RadrootsRelayOutcome,
+}
+
+impl RadrootsOutboxPublishReceipt {
+ pub const fn local_ingest(&self) -> &RadrootsOutboxEventStoreIngestReceipt {
+ &self.local_ingest
+ }
+
+ pub fn event_id(&self) -> &str {
+ self.event_id.as_str()
+ }
+
+ pub const fn attempted_count(&self) -> usize {
+ self.attempted_count
+ }
+
+ pub const fn accepted_count(&self) -> usize {
+ self.accepted_count
+ }
+
+ pub const fn retryable_count(&self) -> usize {
+ self.retryable_count
+ }
+
+ pub const fn terminal_count(&self) -> usize {
+ self.terminal_count
+ }
+
+ pub const fn quorum(&self) -> usize {
+ self.quorum
+ }
+
+ pub const fn quorum_met(&self) -> bool {
+ self.quorum_met
+ }
+
+ pub fn target_receipts(&self) -> &[RadrootsOutboxPublishTargetReceipt] {
+ self.target_receipts.as_slice()
+ }
+
+ pub fn relay_receipts(&self) -> &[RadrootsRelayPublishRelayReceipt] {
+ self.relay_receipts.as_slice()
+ }
+}
+
+impl RadrootsOutboxPublishTargetReceipt {
+ pub const fn delivery_target_id(&self) -> i64 {
+ self.delivery_target_id
+ }
+
+ pub fn endpoint_uri(&self) -> &str {
+ self.endpoint_uri.as_str()
+ }
+
+ pub const fn endpoint_fingerprint(&self) -> &RadrootsTransportTargetFingerprint {
+ &self.endpoint_fingerprint
+ }
+
+ pub fn target_scope(&self) -> Option<&str> {
+ self.target_scope.as_deref()
+ }
+
+ pub fn target_label(&self) -> Option<&str> {
+ self.target_label.as_deref()
+ }
+
+ pub const fn attempted(&self) -> bool {
+ self.attempted
+ }
+
+ pub const fn transport_status(&self) -> RadrootsTransportDeliveryTargetStatus {
+ self.transport_status
+ }
+
+ pub const fn outcome(&self) -> &RadrootsRelayOutcome {
+ &self.outcome
+ }
}
pub fn phase1_publication_delivery_request(
diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs
@@ -564,6 +564,7 @@ impl<A> RadrootsNostrTransport<A> {
true,
"ready",
)
+ .expect("static Nostr transport status")
.with_capabilities(RadrootsTransportCapabilities::deliver_only()),
}
}
diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs
@@ -326,13 +326,13 @@ impl RadrootsTransport for ScriptedTransport {
fn status<'a>(&'a self) -> RadrootsTransportFuture<'a, RadrootsTransportStatus> {
Box::pin(async {
- Ok(RadrootsTransportStatus::new(
+ RadrootsTransportStatus::new(
RadrootsTransportKind::Nostr,
true,
RadrootsTransportImplementationState::Real,
true,
"scripted",
- ))
+ )
})
}
@@ -378,13 +378,13 @@ impl RadrootsTransport for ForgedReceiptTransport {
fn status<'a>(&'a self) -> RadrootsTransportFuture<'a, RadrootsTransportStatus> {
Box::pin(async {
- Ok(RadrootsTransportStatus::new(
+ RadrootsTransportStatus::new(
RadrootsTransportKind::Nostr,
true,
RadrootsTransportImplementationState::Real,
true,
"forged receipt fixture",
- ))
+ )
})
}
@@ -1480,7 +1480,8 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() {
RadrootsTransportImplementationState::Real,
true,
"fixture ready",
- );
+ )
+ .expect("fixture status");
let transport = RadrootsNostrTransport::new(&adapter).with_status(expected_status.clone());
assert_eq!(transport.transport_kind(), RadrootsTransportKind::Nostr);
assert!(transport.adapter().captured_raw_events().is_empty());
@@ -3991,6 +3992,24 @@ async fn fetch_receipts_enforce_outer_item_and_complete_diagnostic_budgets() {
));
}
+#[test]
+fn outbox_publish_policy_rejects_negative_time_and_seals_configuration() {
+ assert!(matches!(
+ RadrootsOutboxPublishPolicy::new(-1),
+ Err(RadrootsRelayTransportError::InvalidTimestamp {
+ field: "next_attempt_after_ms",
+ value: -1,
+ })
+ ));
+ let policy = RadrootsOutboxPublishPolicy::new(0)
+ .expect("zero next-attempt timestamp")
+ .republish_accepted_relays(true)
+ .with_relay_url_policy(RadrootsRelayUrlPolicy::Localhost);
+ assert_eq!(policy.next_attempt_after_ms(), 0);
+ assert!(policy.should_republish_accepted_relays());
+ assert_eq!(policy.relay_url_policy(), RadrootsRelayUrlPolicy::Localhost);
+}
+
#[tokio::test]
async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() {
let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
@@ -4037,15 +4056,15 @@ async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() {
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("publish");
- assert_eq!(first.attempted_count, 3);
- assert_eq!(first.accepted_count, 2);
- assert!(!first.quorum_met);
+ assert_eq!(first.attempted_count(), 3);
+ assert_eq!(first.accepted_count(), 2);
+ assert!(!first.quorum_met());
let event = outbox
.get_event(receipt.outbox_event_id)
.await
@@ -4094,14 +4113,14 @@ async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() {
&store,
&retry_adapter,
&retry_claim,
- RadrootsOutboxPublishPolicy::new(3_000),
+ RadrootsOutboxPublishPolicy::new(3_000).expect("valid publish policy"),
2_600,
)
.await
.expect("retry publish");
- assert_eq!(second.local_ingest.event_id, signed.id_str());
- assert_eq!(second.attempted_count, 1);
+ assert_eq!(second.local_ingest().event_id, signed.id_str());
+ assert_eq!(second.attempted_count(), 1);
assert_eq!(retry_adapter.captured_raw_events().len(), 1);
let event = outbox
@@ -4160,19 +4179,19 @@ async fn outbox_transport_facade_persists_partial_success_and_retryable_failures
&store,
&transport,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("transport publish");
- assert_eq!(published.event_id, signed.id_str());
- assert_eq!(published.attempted_count, 2);
- assert_eq!(published.accepted_count, 1);
- assert_eq!(published.retryable_count, 1);
- assert_eq!(published.terminal_count, 0);
- assert!(!published.quorum_met);
- assert_eq!(published.relay_receipts.len(), 2);
+ assert_eq!(published.event_id(), signed.id_str());
+ assert_eq!(published.attempted_count(), 2);
+ assert_eq!(published.accepted_count(), 1);
+ assert_eq!(published.retryable_count(), 1);
+ assert_eq!(published.terminal_count(), 0);
+ assert!(!published.quorum_met());
+ assert_eq!(published.relay_receipts().len(), 2);
let targets = outbox
.delivery_targets(receipt.outbox_event_id)
.await
@@ -4265,20 +4284,20 @@ async fn outbox_transport_facade_persists_every_delivery_status() {
&store,
&transport,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("transport publish");
- assert_eq!(published.event_id, signed.id_str());
- assert_eq!(published.attempted_count, 14);
- assert_eq!(published.accepted_count, 6);
- assert_eq!(published.retryable_count, 3);
- assert_eq!(published.terminal_count, 5);
- assert!(!published.quorum_met);
- assert_eq!(published.target_receipts.len(), 14);
- assert_eq!(published.relay_receipts.len(), 14);
+ assert_eq!(published.event_id(), signed.id_str());
+ assert_eq!(published.attempted_count(), 14);
+ assert_eq!(published.accepted_count(), 6);
+ assert_eq!(published.retryable_count(), 3);
+ assert_eq!(published.terminal_count(), 5);
+ assert!(!published.quorum_met());
+ assert_eq!(published.target_receipts().len(), 14);
+ assert_eq!(published.relay_receipts().len(), 14);
let targets = outbox
.delivery_targets(receipt.outbox_event_id)
.await
@@ -4360,7 +4379,7 @@ async fn outbox_transport_facade_rejects_receipts_forged_for_another_request() {
&store,
&ForgedReceiptTransport { forged },
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
@@ -4422,15 +4441,15 @@ async fn outbox_transport_facade_handles_empty_and_invalid_claim_plans() {
&store,
&ScriptedTransport::new(Vec::new()),
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("already satisfied publish");
- assert_eq!(published.event_id, signed.id_str());
- assert_eq!(published.attempted_count, 0);
- assert_eq!(published.accepted_count, 2);
- assert!(published.quorum_met);
+ assert_eq!(published.event_id(), signed.id_str());
+ assert_eq!(published.attempted_count(), 0);
+ assert_eq!(published.accepted_count(), 2);
+ assert!(published.quorum_met());
let second_draft = generic_draft("invalid claimed plan");
outbox
@@ -4458,7 +4477,7 @@ async fn outbox_transport_facade_handles_empty_and_invalid_claim_plans() {
&store,
&ScriptedTransport::new(Vec::new()),
&invalid_claim,
- RadrootsOutboxPublishPolicy::new(3_500),
+ RadrootsOutboxPublishPolicy::new(3_500).expect("valid publish policy"),
2_400,
)
.await
@@ -4471,7 +4490,7 @@ async fn outbox_transport_facade_handles_empty_and_invalid_claim_plans() {
&store,
&ScriptedTransport::new(Vec::new()),
&invalid_claim,
- RadrootsOutboxPublishPolicy::new(3_500),
+ RadrootsOutboxPublishPolicy::new(3_500).expect("valid publish policy"),
2_401,
)
.await
@@ -4509,7 +4528,7 @@ async fn outbox_transport_facade_requires_signed_claims() {
&store,
&ScriptedTransport::new(Vec::new()),
&claimed,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
1_100,
)
.await
@@ -4550,7 +4569,7 @@ async fn outbox_transport_facade_rejects_non_nostr_transport_before_mutation() {
&store,
&transport,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
@@ -4630,36 +4649,36 @@ async fn outbox_publish_fans_out_endpoint_receipts_to_scoped_logical_targets() {
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.local_ingest.event_id, signed.id_str());
- assert_eq!(published.event_id, signed.id_str());
- assert_eq!(published.attempted_count, 2);
- assert_eq!(published.accepted_count, 2);
- assert_eq!(published.retryable_count, 0);
- assert_eq!(published.terminal_count, 0);
- assert_eq!(published.quorum, 2);
- assert!(published.quorum_met);
- assert_eq!(published.relay_receipts.len(), 1);
- assert_eq!(published.relay_receipts[0].relay_url(), RELAY_PRIMARY_WSS);
- assert_eq!(published.target_receipts.len(), 2);
+ assert_eq!(published.local_ingest().event_id, signed.id_str());
+ assert_eq!(published.event_id(), signed.id_str());
+ assert_eq!(published.attempted_count(), 2);
+ assert_eq!(published.accepted_count(), 2);
+ assert_eq!(published.retryable_count(), 0);
+ assert_eq!(published.terminal_count(), 0);
+ assert_eq!(published.quorum(), 2);
+ assert!(published.quorum_met());
+ assert_eq!(published.relay_receipts().len(), 1);
+ assert_eq!(published.relay_receipts()[0].relay_url(), RELAY_PRIMARY_WSS);
+ assert_eq!(published.target_receipts().len(), 2);
assert!(
published
- .target_receipts
+ .target_receipts()
.iter()
- .all(|target| target.endpoint_uri == RELAY_PRIMARY_WSS && target.attempted)
+ .all(|target| target.endpoint_uri() == RELAY_PRIMARY_WSS && target.attempted())
);
- assert!(published.target_receipts.iter().any(|target| {
- target.target_scope.as_deref() == Some("foodshed.west")
- && target.target_label.as_deref() == Some("West foodshed")
+ assert!(published.target_receipts().iter().any(|target| {
+ target.target_scope() == Some("foodshed.west")
+ && target.target_label() == Some("West foodshed")
}));
- assert!(published.target_receipts.iter().any(|target| {
- target.target_scope.as_deref() == Some("foodshed.east")
- && target.target_label.as_deref() == Some("East foodshed")
+ assert!(published.target_receipts().iter().any(|target| {
+ target.target_scope() == Some("foodshed.east")
+ && target.target_label() == Some("East foodshed")
}));
assert_eq!(adapter.captured_raw_events().len(), 1);
@@ -4735,34 +4754,34 @@ async fn outbox_transport_facade_fans_out_endpoint_receipts_to_scoped_logical_ta
&store,
&transport,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("transport publish");
- assert_eq!(published.local_ingest.event_id, signed.id_str());
- assert_eq!(published.event_id, signed.id_str());
- assert_eq!(published.attempted_count, 2);
- assert_eq!(published.accepted_count, 2);
- assert_eq!(published.retryable_count, 0);
- assert_eq!(published.terminal_count, 0);
- assert_eq!(published.quorum, 2);
- assert!(published.quorum_met);
- assert_eq!(published.relay_receipts.len(), 1);
- assert_eq!(published.target_receipts.len(), 2);
- assert!(published.target_receipts.iter().all(|target| {
- target.endpoint_uri == RELAY_PRIMARY_WSS
- && target.attempted
- && target.transport_status == RadrootsTransportDeliveryTargetStatus::Accepted
+ assert_eq!(published.local_ingest().event_id, signed.id_str());
+ assert_eq!(published.event_id(), signed.id_str());
+ assert_eq!(published.attempted_count(), 2);
+ assert_eq!(published.accepted_count(), 2);
+ assert_eq!(published.retryable_count(), 0);
+ assert_eq!(published.terminal_count(), 0);
+ assert_eq!(published.quorum(), 2);
+ assert!(published.quorum_met());
+ assert_eq!(published.relay_receipts().len(), 1);
+ assert_eq!(published.target_receipts().len(), 2);
+ assert!(published.target_receipts().iter().all(|target| {
+ target.endpoint_uri() == RELAY_PRIMARY_WSS
+ && target.attempted()
+ && target.transport_status() == RadrootsTransportDeliveryTargetStatus::Accepted
}));
- assert!(published.target_receipts.iter().any(|target| {
- target.target_scope.as_deref() == Some("foodshed.west")
- && target.target_label.as_deref() == Some("West foodshed")
+ assert!(published.target_receipts().iter().any(|target| {
+ target.target_scope() == Some("foodshed.west")
+ && target.target_label() == Some("West foodshed")
}));
- assert!(published.target_receipts.iter().any(|target| {
- target.target_scope.as_deref() == Some("foodshed.east")
- && target.target_label.as_deref() == Some("East foodshed")
+ assert!(published.target_receipts().iter().any(|target| {
+ target.target_scope() == Some("foodshed.east")
+ && target.target_label() == Some("East foodshed")
}));
assert_eq!(adapter.captured_raw_events().len(), 1);
@@ -4860,19 +4879,22 @@ async fn outbox_publish_required_target_failure_is_not_satisfied_by_optional_suc
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.attempted_count, 1);
- assert_eq!(published.accepted_count, 0);
- assert_eq!(published.retryable_count, 1);
- assert_eq!(published.quorum, 1);
- assert!(!published.quorum_met);
- assert_eq!(published.relay_receipts.len(), 1);
- assert_eq!(published.relay_receipts[0].relay_url(), RELAY_SECONDARY_WSS);
+ assert_eq!(published.attempted_count(), 1);
+ assert_eq!(published.accepted_count(), 0);
+ assert_eq!(published.retryable_count(), 1);
+ assert_eq!(published.quorum(), 1);
+ assert!(!published.quorum_met());
+ assert_eq!(published.relay_receipts().len(), 1);
+ assert_eq!(
+ published.relay_receipts()[0].relay_url(),
+ RELAY_SECONDARY_WSS
+ );
let event = outbox
.get_event(receipt.outbox_event_id)
.await
@@ -4953,18 +4975,18 @@ async fn outbox_publish_required_target_success_is_not_blocked_by_optional_retry
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.local_ingest.event_id, signed.id_str());
- assert_eq!(published.attempted_count, 1);
- assert_eq!(published.accepted_count, 1);
- assert_eq!(published.retryable_count, 0);
- assert_eq!(published.quorum, 1);
- assert!(published.quorum_met);
+ assert_eq!(published.local_ingest().event_id, signed.id_str());
+ assert_eq!(published.attempted_count(), 1);
+ assert_eq!(published.accepted_count(), 1);
+ assert_eq!(published.retryable_count(), 0);
+ assert_eq!(published.quorum(), 1);
+ assert!(published.quorum_met());
let event = outbox
.get_event(receipt.outbox_event_id)
.await
@@ -5069,25 +5091,27 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts()
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500).republish_accepted_relays(true),
+ RadrootsOutboxPublishPolicy::new(2_500)
+ .expect("valid publish policy")
+ .republish_accepted_relays(true),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.attempted_count, 2);
- assert_eq!(published.accepted_count, 2);
- assert_eq!(published.quorum, 1);
- assert!(published.quorum_met);
- assert_eq!(published.relay_receipts.len(), 1);
- assert_eq!(published.target_receipts.len(), 2);
- assert!(published.target_receipts.iter().any(|target| {
- &target.endpoint_fingerprint == required.fingerprint()
- && target.target_scope.as_deref() == Some("foodshed.west")
+ assert_eq!(published.attempted_count(), 2);
+ assert_eq!(published.accepted_count(), 2);
+ assert_eq!(published.quorum(), 1);
+ assert!(published.quorum_met());
+ assert_eq!(published.relay_receipts().len(), 1);
+ assert_eq!(published.target_receipts().len(), 2);
+ assert!(published.target_receipts().iter().any(|target| {
+ target.endpoint_fingerprint() == required.fingerprint()
+ && target.target_scope() == Some("foodshed.west")
}));
- assert!(published.target_receipts.iter().any(|target| {
- &target.endpoint_fingerprint == optional.fingerprint()
- && target.target_scope.as_deref() == Some("foodshed.east")
+ assert!(published.target_receipts().iter().any(|target| {
+ target.endpoint_fingerprint() == optional.fingerprint()
+ && target.target_scope() == Some("foodshed.east")
}));
let event = outbox
.get_event(receipt.outbox_event_id)
@@ -5149,20 +5173,20 @@ async fn outbox_transport_publish_failure_releases_retryable_claim() {
&store,
&TransportFailurePublishAdapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.attempted_count, 2);
- assert_eq!(published.accepted_count, 0);
- assert_eq!(published.retryable_count, 2);
- assert_eq!(published.terminal_count, 0);
- assert!(!published.quorum_met);
+ assert_eq!(published.attempted_count(), 2);
+ assert_eq!(published.accepted_count(), 0);
+ assert_eq!(published.retryable_count(), 2);
+ assert_eq!(published.terminal_count(), 0);
+ assert!(!published.quorum_met());
assert!(
published
- .relay_receipts
+ .relay_receipts()
.iter()
.all(|relay| relay.outcome().kind() == RadrootsRelayOutcomeKind::ConnectionFailed)
);
@@ -5251,20 +5275,20 @@ async fn outbox_publish_marks_published_without_adapter_when_all_relays_already_
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.local_ingest.event_id, signed.id_str());
- assert_eq!(published.event_id, signed.id_str());
- assert_eq!(published.attempted_count, 0);
- assert_eq!(published.accepted_count, 2);
- assert_eq!(published.quorum, 0);
- assert!(published.quorum_met);
- assert!(published.target_receipts.is_empty());
- assert!(published.relay_receipts.is_empty());
+ assert_eq!(published.local_ingest().event_id, signed.id_str());
+ assert_eq!(published.event_id(), signed.id_str());
+ assert_eq!(published.attempted_count(), 0);
+ assert_eq!(published.accepted_count(), 2);
+ assert_eq!(published.quorum(), 0);
+ assert!(published.quorum_met());
+ assert!(published.target_receipts().is_empty());
+ assert!(published.relay_receipts().is_empty());
assert!(adapter.captured_raw_events().is_empty());
let event = outbox
@@ -5311,7 +5335,7 @@ async fn outbox_publish_rejects_unknown_adapter_receipts() {
&store,
&UnknownRelayReceiptPublishAdapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
@@ -5375,13 +5399,13 @@ async fn outbox_publish_skips_non_nostr_targets() {
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.attempted_count, 1);
+ assert_eq!(published.attempted_count(), 1);
assert_eq!(adapter.captured_raw_events().len(), 1);
let event = outbox
.get_event(receipt.outbox_event_id)
@@ -5448,16 +5472,16 @@ async fn outbox_publish_marks_published_when_delivery_plan_satisfaction_is_met_w
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.quorum, 2);
- assert_eq!(published.accepted_count, 2);
- assert_eq!(published.terminal_count, 1);
- assert!(published.quorum_met);
+ assert_eq!(published.quorum(), 2);
+ assert_eq!(published.accepted_count(), 2);
+ assert_eq!(published.terminal_count(), 1);
+ assert!(published.quorum_met());
let event = outbox
.get_event(receipt.outbox_event_id)
@@ -5543,18 +5567,19 @@ async fn outbox_publish_republishes_accepted_relays_when_policy_requests_it() {
&adapter,
&publish_claim,
RadrootsOutboxPublishPolicy::new(2_500)
+ .expect("valid publish policy")
.republish_accepted_relays(true)
- .relay_url_policy(RadrootsRelayUrlPolicy::Public),
+ .with_relay_url_policy(RadrootsRelayUrlPolicy::Public),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.local_ingest.event_id, signed.id_str());
- assert_eq!(published.attempted_count, 2);
- assert_eq!(published.accepted_count, 2);
- assert_eq!(published.quorum, 1);
- assert!(published.quorum_met);
+ assert_eq!(published.local_ingest().event_id, signed.id_str());
+ assert_eq!(published.attempted_count(), 2);
+ assert_eq!(published.accepted_count(), 2);
+ assert_eq!(published.quorum(), 1);
+ assert!(published.quorum_met());
assert_eq!(adapter.captured_raw_events().len(), 1);
let event = outbox
@@ -5626,16 +5651,18 @@ async fn outbox_publish_republish_policy_keeps_terminal_targets_excluded() {
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500).republish_accepted_relays(true),
+ RadrootsOutboxPublishPolicy::new(2_500)
+ .expect("valid publish policy")
+ .republish_accepted_relays(true),
2_200,
)
.await
.expect("publish");
- assert_eq!(published.attempted_count, 1);
- assert_eq!(published.accepted_count, 1);
- assert_eq!(published.quorum, 1);
- assert!(published.quorum_met);
+ assert_eq!(published.attempted_count(), 1);
+ assert_eq!(published.accepted_count(), 1);
+ assert_eq!(published.quorum(), 1);
+ assert!(published.quorum_met());
assert_eq!(adapter.captured_raw_events().len(), 1);
let event = outbox
.get_event(receipt.outbox_event_id)
@@ -5669,7 +5696,7 @@ async fn outbox_publish_requires_claimed_signed_event() {
&store,
&adapter,
&claimed,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
1_100,
)
.await
@@ -5713,7 +5740,7 @@ async fn outbox_publish_propagates_non_transport_adapter_errors_after_target_fil
&store,
&NostrJsonFailurePublishAdapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
@@ -5761,7 +5788,7 @@ async fn outbox_publish_rejects_invalid_relay_target_uri_before_adapter_publish(
&store,
&adapter,
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_200,
)
.await
@@ -5778,7 +5805,7 @@ async fn outbox_publish_rejects_invalid_relay_target_uri_before_adapter_publish(
&store,
&ScriptedTransport::new(Vec::new()),
&publish_claim,
- RadrootsOutboxPublishPolicy::new(2_500),
+ RadrootsOutboxPublishPolicy::new(2_500).expect("valid publish policy"),
2_201,
)
.await
diff --git a/crates/transport_reticulum/src/lib.rs b/crates/transport_reticulum/src/lib.rs
@@ -10,15 +10,16 @@ use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;
use radroots_transport::{
- RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransport,
- RadrootsTransportCapabilities, RadrootsTransportCapabilityAvailability,
- RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryReceipt,
- RadrootsTransportDeliveryRequest, RadrootsTransportError, RadrootsTransportFetchReceipt,
- RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState,
- RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome,
- RadrootsTransportOutcomeKind, RadrootsTransportStatus, RadrootsTransportTarget,
- RadrootsTransportTargetReceipt, ReticulumCapabilityReportV1, ReticulumDestinationV1,
- ReticulumPayloadPolicyV1,
+ RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE,
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT,
+ RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, RadrootsTransport, RadrootsTransportCapabilities,
+ RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity,
+ RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, RadrootsTransportError,
+ RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture,
+ RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId,
+ RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportStatus,
+ RadrootsTransportTarget, RadrootsTransportTargetReceipt, ReticulumCapabilityReportV1,
+ ReticulumDestinationV1,
};
const DEFAULT_PROFILE_ID: &str = "transport.reticulum.default";
@@ -35,7 +36,7 @@ pub enum RadrootsReticulumBehavior {
DeferDeliveryPlans,
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsReticulumEndpoint {
uri: String,
@@ -73,7 +74,26 @@ impl fmt::Display for RadrootsReticulumEndpoint {
}
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RadrootsReticulumEndpointWire {
+ #[serde(deserialize_with = "deserialize_endpoint_uri")]
+ uri: String,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for RadrootsReticulumEndpoint {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = RadrootsReticulumEndpointWire::deserialize(deserializer)?;
+ Self::parse(wire.uri).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsReticulumAgentEndpoint {
uri: String,
@@ -89,6 +109,7 @@ impl RadrootsReticulumAgentEndpoint {
.any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
|| !uri.starts_with(RETICULUM_AGENT_ENDPOINT_PREFIX)
|| uri.len() == RETICULUM_AGENT_ENDPOINT_PREFIX.len()
+ || uri.len() > RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES
{
return Err(RadrootsReticulumError::InvalidAgentEndpoint);
}
@@ -112,7 +133,26 @@ impl fmt::Display for RadrootsReticulumAgentEndpoint {
}
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RadrootsReticulumAgentEndpointWire {
+ #[serde(deserialize_with = "deserialize_endpoint_uri")]
+ uri: String,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for RadrootsReticulumAgentEndpoint {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = RadrootsReticulumAgentEndpointWire::deserialize(deserializer)?;
+ Self::parse(wire.uri).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsReticulumProfile {
profile_id: String,
@@ -133,16 +173,16 @@ impl RadrootsReticulumProfile {
behavior: RadrootsReticulumBehavior,
) -> Result<Self, RadrootsReticulumError> {
let profile_id = profile_id.into();
- if profile_id.trim().is_empty() || profile_id.chars().any(char::is_whitespace) {
+ if profile_id.trim().is_empty()
+ || profile_id.chars().any(char::is_whitespace)
+ || profile_id.len() > RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES
+ {
return Err(RadrootsReticulumError::InvalidProfileId);
}
let destination = ReticulumDestinationV1::new(endpoint.as_str(), scope.clone(), None)
.map_err(|_| RadrootsReticulumError::InvalidEndpoint)?;
- let capability_report = ReticulumCapabilityReportV1 {
- destination: destination.clone(),
- payload_policy: ReticulumPayloadPolicyV1::v1(),
- ..ReticulumCapabilityReportV1::unavailable_local()
- };
+ let capability_report =
+ ReticulumCapabilityReportV1::unavailable(destination.clone(), agent_endpoint.is_none());
Ok(Self {
profile_id,
endpoint,
@@ -162,7 +202,7 @@ impl RadrootsReticulumProfile {
scope: RadrootsTransportMeshScopeId::local_reticulum(),
agent_endpoint: None,
behavior: RadrootsReticulumBehavior::RejectDeliveryAttempts,
- destination: capability_report.destination.clone(),
+ destination: capability_report.destination().clone(),
capability_report,
}
}
@@ -190,6 +230,8 @@ impl RadrootsReticulumProfile {
pub fn with_agent_endpoint(mut self, agent_endpoint: RadrootsReticulumAgentEndpoint) -> Self {
self.agent_endpoint = Some(agent_endpoint);
+ self.capability_report =
+ ReticulumCapabilityReportV1::unavailable(self.destination.clone(), false);
self
}
@@ -206,25 +248,29 @@ impl RadrootsReticulumProfile {
}
pub fn status(&self) -> RadrootsReticulumStatus {
- RadrootsReticulumStatus {
- behavior: self.behavior,
- scope: self.scope.clone(),
- agent_endpoint: self.agent_endpoint.clone(),
- destination: self.destination.clone(),
- capability_report: self.capability_report.clone(),
- transport_status: RadrootsTransportStatus::new(
- RadrootsTransportKind::Reticulum,
- true,
- RadrootsTransportImplementationState::Real,
- false,
- RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE,
- )
- .with_maturity(RadrootsTransportCapabilityMaturity::Preview)
- .with_availability(RadrootsTransportCapabilityAvailability::Unavailable)
- .with_capabilities(RadrootsTransportCapabilities::reticulum_unavailable())
- .with_profile_id(self.profile_id.clone())
- .with_endpoint_uri(self.endpoint.as_str()),
- }
+ let transport_status = RadrootsTransportStatus::new(
+ RadrootsTransportKind::Reticulum,
+ true,
+ RadrootsTransportImplementationState::Real,
+ false,
+ RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE,
+ )
+ .expect("static Reticulum transport status")
+ .with_maturity(RadrootsTransportCapabilityMaturity::Preview)
+ .with_availability(RadrootsTransportCapabilityAvailability::Unavailable)
+ .with_capabilities(RadrootsTransportCapabilities::reticulum_unavailable())
+ .try_with_profile_id(self.profile_id.clone())
+ .and_then(|status| status.try_with_endpoint_uri(self.endpoint.as_str()))
+ .expect("validated Reticulum profile status");
+ RadrootsReticulumStatus::new(
+ self.behavior,
+ self.scope.clone(),
+ self.agent_endpoint.clone(),
+ self.destination.clone(),
+ self.capability_report.clone(),
+ transport_status,
+ )
+ .expect("validated Reticulum profile status")
}
}
@@ -234,15 +280,149 @@ impl Default for RadrootsReticulumProfile {
}
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RadrootsReticulumProfileWire {
+ #[serde(deserialize_with = "deserialize_identifier")]
+ profile_id: String,
+ endpoint: RadrootsReticulumEndpoint,
+ scope: RadrootsTransportMeshScopeId,
+ agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
+ behavior: RadrootsReticulumBehavior,
+ destination: ReticulumDestinationV1,
+ capability_report: ReticulumCapabilityReportV1,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for RadrootsReticulumProfile {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = RadrootsReticulumProfileWire::deserialize(deserializer)?;
+ let profile = Self::new(
+ wire.profile_id,
+ wire.endpoint,
+ wire.scope,
+ wire.agent_endpoint,
+ wire.behavior,
+ )
+ .map_err(serde::de::Error::custom)?;
+ if profile.destination != wire.destination
+ || profile.capability_report != wire.capability_report
+ {
+ return Err(serde::de::Error::custom(
+ "Reticulum profile derived authority does not match its inputs",
+ ));
+ }
+ Ok(profile)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsReticulumStatus {
- pub behavior: RadrootsReticulumBehavior,
- pub scope: RadrootsTransportMeshScopeId,
- pub agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
- pub destination: ReticulumDestinationV1,
- pub capability_report: ReticulumCapabilityReportV1,
- pub transport_status: RadrootsTransportStatus,
+ behavior: RadrootsReticulumBehavior,
+ scope: RadrootsTransportMeshScopeId,
+ agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
+ destination: ReticulumDestinationV1,
+ capability_report: ReticulumCapabilityReportV1,
+ transport_status: RadrootsTransportStatus,
+}
+
+impl RadrootsReticulumStatus {
+ fn new(
+ behavior: RadrootsReticulumBehavior,
+ scope: RadrootsTransportMeshScopeId,
+ agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
+ destination: ReticulumDestinationV1,
+ capability_report: ReticulumCapabilityReportV1,
+ transport_status: RadrootsTransportStatus,
+ ) -> Result<Self, RadrootsReticulumError> {
+ let expected_report =
+ ReticulumCapabilityReportV1::unavailable(destination.clone(), agent_endpoint.is_none());
+ if destination.routing().scope() != &scope
+ || capability_report != expected_report
+ || transport_status.kind() != &RadrootsTransportKind::Reticulum
+ || !transport_status.is_configured()
+ || transport_status.implementation() != RadrootsTransportImplementationState::Real
+ || transport_status.maturity() != RadrootsTransportCapabilityMaturity::Preview
+ || transport_status.availability()
+ != RadrootsTransportCapabilityAvailability::Unavailable
+ || transport_status.is_usable_for_delivery()
+ || transport_status.capabilities()
+ != &RadrootsTransportCapabilities::reticulum_unavailable()
+ || transport_status.profile_id().is_none()
+ || transport_status.endpoint_uri() != Some(destination.uri().as_str())
+ || transport_status.message() != RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE
+ {
+ return Err(RadrootsReticulumError::InvalidStatus);
+ }
+ Ok(Self {
+ behavior,
+ scope,
+ agent_endpoint,
+ destination,
+ capability_report,
+ transport_status,
+ })
+ }
+
+ pub const fn behavior(&self) -> RadrootsReticulumBehavior {
+ self.behavior
+ }
+
+ pub const fn scope(&self) -> &RadrootsTransportMeshScopeId {
+ &self.scope
+ }
+
+ pub fn agent_endpoint(&self) -> Option<&RadrootsReticulumAgentEndpoint> {
+ self.agent_endpoint.as_ref()
+ }
+
+ pub const fn destination(&self) -> &ReticulumDestinationV1 {
+ &self.destination
+ }
+
+ pub const fn capability_report(&self) -> &ReticulumCapabilityReportV1 {
+ &self.capability_report
+ }
+
+ pub const fn transport_status(&self) -> &RadrootsTransportStatus {
+ &self.transport_status
+ }
+}
+
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RadrootsReticulumStatusWire {
+ behavior: RadrootsReticulumBehavior,
+ scope: RadrootsTransportMeshScopeId,
+ agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
+ destination: ReticulumDestinationV1,
+ capability_report: ReticulumCapabilityReportV1,
+ transport_status: RadrootsTransportStatus,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for RadrootsReticulumStatus {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = RadrootsReticulumStatusWire::deserialize(deserializer)?;
+ Self::new(
+ wire.behavior,
+ wire.scope,
+ wire.agent_endpoint,
+ wire.destination,
+ wire.capability_report,
+ wire.transport_status,
+ )
+ .map_err(serde::de::Error::custom)
+ }
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -285,18 +465,15 @@ impl RadrootsReticulumTransport {
&self,
request: RadrootsReticulumFetchRequest,
) -> Result<RadrootsReticulumFetchReceipt, RadrootsReticulumError> {
- if request.max_events == 0 {
- return Err(RadrootsReticulumError::InvalidFetchLimit);
- }
- Ok(RadrootsReticulumFetchReceipt {
- request_id: request.request_id,
- endpoint_uri: self.profile.endpoint.as_str().to_owned(),
- scope: self.profile.scope.clone(),
- agent_endpoint: self.profile.agent_endpoint.clone(),
- outcome: reticulum_outcome(self.profile.behavior),
- observed_event_count: 0,
- implementation: RadrootsTransportImplementationState::Real,
- })
+ RadrootsReticulumFetchReceipt::new(
+ request.request_id,
+ self.profile.endpoint.as_str().to_owned(),
+ self.profile.scope.clone(),
+ self.profile.agent_endpoint.clone(),
+ reticulum_outcome(self.profile.behavior),
+ 0,
+ RadrootsTransportImplementationState::Real,
+ )
}
}
@@ -312,7 +489,7 @@ impl RadrootsTransport for RadrootsReticulumTransport {
}
fn status<'a>(&'a self) -> RadrootsTransportFuture<'a, RadrootsTransportStatus> {
- Box::pin(async move { Ok(self.profile.status().transport_status) })
+ Box::pin(async move { Ok(self.profile.status().transport_status().clone()) })
}
fn deliver<'a>(
@@ -345,11 +522,11 @@ impl RadrootsTransport for RadrootsReticulumTransport {
}
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsReticulumFetchRequest {
- pub request_id: String,
- pub max_events: u16,
+ request_id: String,
+ max_events: u16,
}
impl RadrootsReticulumFetchRequest {
@@ -357,26 +534,157 @@ impl RadrootsReticulumFetchRequest {
request_id: impl Into<String>,
max_events: u16,
) -> Result<Self, RadrootsReticulumError> {
- if max_events == 0 {
+ let request_id = request_id.into();
+ if !is_valid_identifier(request_id.as_str()) {
+ return Err(RadrootsReticulumError::InvalidFetchRequestId);
+ }
+ if max_events == 0
+ || usize::from(max_events) > RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT
+ {
return Err(RadrootsReticulumError::InvalidFetchLimit);
}
Ok(Self {
- request_id: request_id.into(),
+ request_id,
max_events,
})
}
+
+ pub fn request_id(&self) -> &str {
+ self.request_id.as_str()
+ }
+
+ pub const fn max_events(&self) -> u16 {
+ self.max_events
+ }
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RadrootsReticulumFetchRequestWire {
+ #[serde(deserialize_with = "deserialize_identifier")]
+ request_id: String,
+ max_events: u16,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for RadrootsReticulumFetchRequest {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = RadrootsReticulumFetchRequestWire::deserialize(deserializer)?;
+ Self::new(wire.request_id, wire.max_events).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsReticulumFetchReceipt {
- pub request_id: String,
- pub endpoint_uri: String,
- pub scope: RadrootsTransportMeshScopeId,
- pub agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
- pub outcome: RadrootsTransportOutcome,
- pub observed_event_count: usize,
- pub implementation: RadrootsTransportImplementationState,
+ request_id: String,
+ endpoint_uri: String,
+ scope: RadrootsTransportMeshScopeId,
+ agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
+ outcome: RadrootsTransportOutcome,
+ observed_event_count: usize,
+ implementation: RadrootsTransportImplementationState,
+}
+
+impl RadrootsReticulumFetchReceipt {
+ fn new(
+ request_id: String,
+ endpoint_uri: String,
+ scope: RadrootsTransportMeshScopeId,
+ agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
+ outcome: RadrootsTransportOutcome,
+ observed_event_count: usize,
+ implementation: RadrootsTransportImplementationState,
+ ) -> Result<Self, RadrootsReticulumError> {
+ if !is_valid_identifier(request_id.as_str())
+ || RadrootsReticulumEndpoint::parse(endpoint_uri.as_str()).is_err()
+ || observed_event_count != 0
+ || implementation != RadrootsTransportImplementationState::Real
+ || !matches!(
+ outcome.kind(),
+ RadrootsTransportOutcomeKind::TransportUnavailable
+ | RadrootsTransportOutcomeKind::DeferredUntilImplemented
+ )
+ {
+ return Err(RadrootsReticulumError::InvalidFetchReceipt);
+ }
+ Ok(Self {
+ request_id,
+ endpoint_uri,
+ scope,
+ agent_endpoint,
+ outcome,
+ observed_event_count,
+ implementation,
+ })
+ }
+
+ pub fn request_id(&self) -> &str {
+ self.request_id.as_str()
+ }
+
+ pub fn endpoint_uri(&self) -> &str {
+ self.endpoint_uri.as_str()
+ }
+
+ pub const fn scope(&self) -> &RadrootsTransportMeshScopeId {
+ &self.scope
+ }
+
+ pub fn agent_endpoint(&self) -> Option<&RadrootsReticulumAgentEndpoint> {
+ self.agent_endpoint.as_ref()
+ }
+
+ pub const fn outcome(&self) -> &RadrootsTransportOutcome {
+ &self.outcome
+ }
+
+ pub const fn observed_event_count(&self) -> usize {
+ self.observed_event_count
+ }
+
+ pub const fn implementation(&self) -> RadrootsTransportImplementationState {
+ self.implementation
+ }
+}
+
+#[cfg(feature = "serde")]
+#[derive(serde::Deserialize)]
+#[serde(deny_unknown_fields)]
+struct RadrootsReticulumFetchReceiptWire {
+ #[serde(deserialize_with = "deserialize_identifier")]
+ request_id: String,
+ #[serde(deserialize_with = "deserialize_endpoint_uri")]
+ endpoint_uri: String,
+ scope: RadrootsTransportMeshScopeId,
+ agent_endpoint: Option<RadrootsReticulumAgentEndpoint>,
+ outcome: RadrootsTransportOutcome,
+ observed_event_count: usize,
+ implementation: RadrootsTransportImplementationState,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for RadrootsReticulumFetchReceipt {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let wire = RadrootsReticulumFetchReceiptWire::deserialize(deserializer)?;
+ Self::new(
+ wire.request_id,
+ wire.endpoint_uri,
+ wire.scope,
+ wire.agent_endpoint,
+ wire.outcome,
+ wire.observed_event_count,
+ wire.implementation,
+ )
+ .map_err(serde::de::Error::custom)
+ }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -385,6 +693,9 @@ pub enum RadrootsReticulumError {
InvalidAgentEndpoint,
InvalidProfileId,
InvalidFetchLimit,
+ InvalidFetchRequestId,
+ InvalidFetchReceipt,
+ InvalidStatus,
NonReticulumTarget,
InvalidDeliveryReceipt,
}
@@ -395,7 +706,10 @@ impl fmt::Display for RadrootsReticulumError {
Self::InvalidEndpoint => "invalid Reticulum endpoint",
Self::InvalidAgentEndpoint => "invalid Reticulum agent endpoint",
Self::InvalidProfileId => "invalid Reticulum profile id",
- Self::InvalidFetchLimit => "Reticulum fetch limit must be greater than zero",
+ Self::InvalidFetchLimit => "Reticulum fetch limit must be between 1 and 1000",
+ Self::InvalidFetchRequestId => "invalid Reticulum fetch request id",
+ Self::InvalidFetchReceipt => "invalid Reticulum fetch receipt",
+ Self::InvalidStatus => "invalid Reticulum status",
Self::NonReticulumTarget => "Reticulum transport received a non-Reticulum target",
Self::InvalidDeliveryReceipt => "Reticulum transport produced an invalid receipt",
})
@@ -410,6 +724,9 @@ fn reticulum_error_to_transport_error(error: RadrootsReticulumError) -> Radroots
RadrootsReticulumError::InvalidAgentEndpoint
| RadrootsReticulumError::InvalidProfileId
| RadrootsReticulumError::InvalidFetchLimit
+ | RadrootsReticulumError::InvalidFetchRequestId
+ | RadrootsReticulumError::InvalidFetchReceipt
+ | RadrootsReticulumError::InvalidStatus
| RadrootsReticulumError::InvalidDeliveryReceipt => {
RadrootsTransportError::InvalidTransportKind
}
@@ -457,6 +774,71 @@ fn reticulum_outcome(behavior: RadrootsReticulumBehavior) -> RadrootsTransportOu
.expect("static Reticulum outcome message is bounded")
}
+fn is_valid_identifier(value: &str) -> bool {
+ !value.is_empty()
+ && value == value.trim()
+ && !value.chars().any(char::is_whitespace)
+ && value.len() <= RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_endpoint_uri<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ deserialize_bounded_string(deserializer, RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES)
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_identifier<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ deserialize_bounded_string(deserializer, RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES)
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_bounded_string<'de, D>(deserializer: D, max: usize) -> Result<String, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ deserializer.deserialize_string(BoundedStringVisitor { max })
+}
+
+#[cfg(feature = "serde")]
+struct BoundedStringVisitor {
+ max: usize,
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::de::Visitor<'de> for BoundedStringVisitor {
+ type Value = String;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(formatter, "a string of at most {} UTF-8 bytes", self.max)
+ }
+
+ fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
+ where
+ E: serde::de::Error,
+ {
+ if value.len() > self.max {
+ return Err(E::invalid_length(value.len(), &self));
+ }
+ Ok(value.to_owned())
+ }
+
+ fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
+ where
+ E: serde::de::Error,
+ {
+ if value.len() > self.max {
+ return Err(E::invalid_length(value.len(), &self));
+ }
+ Ok(value)
+ }
+}
+
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
@@ -557,9 +939,9 @@ mod tests {
);
assert_eq!(
profile.destination(),
- &profile.capability_report().destination
+ profile.capability_report().destination()
);
- assert_eq!(profile.status().behavior, profile.behavior());
+ assert_eq!(profile.status().behavior(), profile.behavior());
let default_profile = RadrootsReticulumProfile::deferred_until_implemented();
assert_eq!(default_profile, RadrootsReticulumProfile::default());
@@ -568,7 +950,7 @@ mod tests {
assert!(RadrootsReticulumFetchRequest::new("invalid", 0).is_err());
let fetch =
RadrootsReticulumFetchRequest::new("fetch".to_string(), 1).expect("fetch request");
- assert_eq!(fetch.request_id, "fetch");
+ assert_eq!(fetch.request_id(), "fetch");
}
#[test]
@@ -587,17 +969,10 @@ mod tests {
rejecting
.fetch(RadrootsReticulumFetchRequest::new("direct-fetch", 1).expect("fetch"))
.expect("direct fetch")
- .observed_event_count,
+ .observed_event_count(),
0
);
- assert!(
- rejecting
- .fetch(RadrootsReticulumFetchRequest {
- request_id: "invalid-fetch".to_owned(),
- max_events: 0,
- })
- .is_err()
- );
+ assert!(RadrootsReticulumFetchRequest::new("invalid-fetch", 0).is_err());
assert_eq!(
RadrootsTransport::transport_kind(&rejecting),
diff --git a/crates/transport_reticulum/tests/reticulum.rs b/crates/transport_reticulum/tests/reticulum.rs
@@ -1,14 +1,19 @@
use radroots_transport::{
RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_SCOPE_ID,
- RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransport,
- RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity,
- RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus,
- RadrootsTransportFetchRequest, RadrootsTransportImplementationState, RadrootsTransportKind,
- RadrootsTransportMeshScopeId, RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
+ RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT,
+ RadrootsTransport, RadrootsTransportCapabilityAvailability,
+ RadrootsTransportCapabilityMaturity, RadrootsTransportDeliveryRequest,
+ RadrootsTransportDeliveryTargetStatus, RadrootsTransportFetchRequest,
+ RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportMeshScopeId,
+ RadrootsTransportPayload, RadrootsTransportSatisfactionClass,
RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetSet,
ReticulumDuplicateFragmentBehaviorV1, ReticulumFragmentIntegrityV1,
ReticulumFragmentationModeV1, ReticulumGatewaySemanticsV1, ReticulumPrivacySemanticsV1,
};
+#[cfg(feature = "serde")]
+use radroots_transport::{
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES,
+};
use radroots_transport_reticulum::{
RadrootsReticulumAgentEndpoint, RadrootsReticulumBehavior, RadrootsReticulumEndpoint,
RadrootsReticulumError, RadrootsReticulumFetchRequest, RadrootsReticulumProfile,
@@ -61,24 +66,24 @@ fn default_profile_is_configured_deferred_until_implemented_and_rejecting() {
RadrootsReticulumBehavior::RejectDeliveryAttempts
);
assert_eq!(
- status.transport_status.implementation,
+ status.transport_status().implementation(),
RadrootsTransportImplementationState::Real
);
assert_eq!(
- status.transport_status.maturity,
+ status.transport_status().maturity(),
RadrootsTransportCapabilityMaturity::Preview
);
assert_eq!(
- status.transport_status.availability,
+ status.transport_status().availability(),
RadrootsTransportCapabilityAvailability::Unavailable
);
- assert!(status.transport_status.configured);
+ assert!(status.transport_status().is_configured());
assert_eq!(
- status.transport_status.profile_id.as_deref(),
+ status.transport_status().profile_id(),
Some("transport.reticulum.default")
);
assert_eq!(
- status.transport_status.endpoint_uri.as_deref(),
+ status.transport_status().endpoint_uri(),
Some(RADROOTS_RETICULUM_ENDPOINT_URI)
);
assert_eq!(
@@ -86,63 +91,67 @@ fn default_profile_is_configured_deferred_until_implemented_and_rejecting() {
RADROOTS_RETICULUM_ENDPOINT_URI
);
assert_eq!(
- profile.destination().routing().scope.as_str(),
+ profile.destination().routing().scope().as_str(),
RADROOTS_RETICULUM_SCOPE_ID
);
assert_eq!(
- status.destination.routing().gateway,
+ status.destination().routing().gateway(),
ReticulumGatewaySemanticsV1::NoGatewayForwarding
);
assert_eq!(
- status.destination.routing().privacy,
+ status.destination().routing().privacy(),
ReticulumPrivacySemanticsV1::CanonicalSignedEventBytesOnly
);
- assert!(status.capability_report.delivery_required);
- assert!(!status.capability_report.fetch_required);
- assert!(!status.capability_report.can_deliver);
- assert!(!status.capability_report.can_fetch);
- assert!(!status.capability_report.can_discover);
- assert!(!status.capability_report.can_forward_gateway);
- assert!(!status.capability_report.can_observe_receipts);
+ assert!(status.capability_report().is_delivery_required());
+ assert!(!status.capability_report().is_fetch_required());
+ assert!(!status.capability_report().can_deliver());
+ assert!(!status.capability_report().can_fetch());
+ assert!(!status.capability_report().can_discover());
+ assert!(!status.capability_report().can_forward_gateway());
+ assert!(!status.capability_report().can_observe_receipts());
assert_eq!(
- status.capability_report.destination.fingerprint(),
- status.destination.fingerprint()
+ status.capability_report().destination().fingerprint(),
+ status.destination().fingerprint()
);
assert_eq!(
- status.capability_report.payload_policy.fragment_policy.mode,
+ status
+ .capability_report()
+ .payload_policy()
+ .fragment_policy()
+ .mode(),
ReticulumFragmentationModeV1::Unsupported
);
assert_eq!(
status
- .capability_report
- .payload_policy
- .fragment_policy
- .max_fragment_count,
+ .capability_report()
+ .payload_policy()
+ .fragment_policy()
+ .max_fragment_count(),
1
);
assert_eq!(
status
- .capability_report
- .payload_policy
- .fragment_policy
- .duplicate_fragment_behavior,
+ .capability_report()
+ .payload_policy()
+ .fragment_policy()
+ .duplicate_fragment_behavior(),
ReticulumDuplicateFragmentBehaviorV1::Reject
);
assert_eq!(
status
- .capability_report
- .payload_policy
- .fragment_policy
- .integrity_verification,
+ .capability_report()
+ .payload_policy()
+ .fragment_policy()
+ .integrity_verification(),
ReticulumFragmentIntegrityV1::PayloadDigest
);
assert_eq!(
- status.transport_status.message,
+ status.transport_status().message(),
RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE
);
- assert!(!status.transport_status.usable_for_delivery);
- assert_eq!(status.scope.as_str(), RADROOTS_RETICULUM_SCOPE_ID);
- assert_eq!(status.agent_endpoint, None);
+ assert!(!status.transport_status().is_usable_for_delivery());
+ assert_eq!(status.scope().as_str(), RADROOTS_RETICULUM_SCOPE_ID);
+ assert_eq!(status.agent_endpoint(), None);
}
#[test]
@@ -276,9 +285,9 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() {
assert_eq!(
profile
.capability_report()
- .destination
+ .destination()
.routing()
- .scope
+ .scope()
.as_str(),
RADROOTS_RETICULUM_SCOPE_ID
);
@@ -341,22 +350,22 @@ fn core_transport_trait_reports_reticulum_status_delivery_and_fetch() {
.expect("target set");
let status = futures::executor::block_on(RadrootsTransport::status(&transport))
.expect("transport status");
- assert_eq!(status.kind, RadrootsTransportKind::Reticulum);
+ assert_eq!(status.kind(), &RadrootsTransportKind::Reticulum);
assert_eq!(
- status.implementation,
+ status.implementation(),
RadrootsTransportImplementationState::Real
);
assert_eq!(
- status.maturity,
+ status.maturity(),
RadrootsTransportCapabilityMaturity::Preview
);
assert_eq!(
- status.availability,
+ status.availability(),
RadrootsTransportCapabilityAvailability::Unavailable
);
- assert!(!status.usable_for_delivery);
- assert!(!status.capabilities.deliver);
- assert!(!status.capabilities.fetch);
+ assert!(!status.is_usable_for_delivery());
+ assert!(!status.capabilities().can_deliver());
+ assert!(!status.capabilities().can_fetch());
let delivery = futures::executor::block_on(RadrootsTransport::deliver(
&transport,
@@ -495,24 +504,24 @@ fn fetch_reports_deferred_until_implemented_without_observed_events() {
"transport.reticulum.default"
);
assert_eq!(
- transport.status().transport_status.implementation,
+ transport.status().transport_status().implementation(),
RadrootsTransportImplementationState::Real
);
let receipt = transport
.fetch(RadrootsReticulumFetchRequest::new("fetch-1", 10).expect("fetch request"))
.expect("fetch receipt");
- assert_eq!(receipt.request_id, "fetch-1");
- assert_eq!(receipt.endpoint_uri, RADROOTS_RETICULUM_ENDPOINT_URI);
- assert_eq!(receipt.observed_event_count, 0);
+ assert_eq!(receipt.request_id(), "fetch-1");
+ assert_eq!(receipt.endpoint_uri(), RADROOTS_RETICULUM_ENDPOINT_URI);
+ assert_eq!(receipt.observed_event_count(), 0);
assert_eq!(
- receipt.implementation,
+ receipt.implementation(),
RadrootsTransportImplementationState::Real
);
- assert_eq!(receipt.scope.as_str(), RADROOTS_RETICULUM_SCOPE_ID);
- assert_eq!(receipt.agent_endpoint, None);
+ 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!(
@@ -520,12 +529,12 @@ fn fetch_reports_deferred_until_implemented_without_observed_events() {
RadrootsReticulumError::InvalidFetchLimit
);
assert_eq!(
- transport
- .fetch(RadrootsReticulumFetchRequest {
- request_id: "fetch-public-zero".to_owned(),
- max_events: 0,
- })
- .expect_err("zero limit at transport boundary"),
+ RadrootsReticulumFetchRequest::new(
+ "fetch-over",
+ u16::try_from(RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT + 1)
+ .expect("one-over limit fits u16"),
+ )
+ .expect_err("one-over limit"),
RadrootsReticulumError::InvalidFetchLimit
);
let deferred_transport = RadrootsReticulumTransport::new(
@@ -536,7 +545,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
);
}
@@ -550,22 +559,29 @@ fn configured_agent_endpoint_is_metadata_only_for_status_delivery_and_fetch() {
);
let status = transport.status();
assert_eq!(
- status
- .agent_endpoint
- .as_ref()
- .map(|endpoint| endpoint.as_str()),
+ status.agent_endpoint().map(|endpoint| endpoint.as_str()),
Some("reticulum-agent://localhost:19999")
);
assert_eq!(
- status.transport_status.implementation,
+ status.transport_status().implementation(),
RadrootsTransportImplementationState::Real
);
- assert!(!status.transport_status.usable_for_delivery);
- assert!(!status.transport_status.capabilities.deliver);
- assert!(!status.transport_status.capabilities.fetch);
- assert!(!status.transport_status.capabilities.discovery);
- assert!(!status.transport_status.capabilities.gateway_forwarding);
- assert!(!status.transport_status.capabilities.receipt_observation);
+ assert!(!status.transport_status().is_usable_for_delivery());
+ assert!(!status.transport_status().capabilities().can_deliver());
+ assert!(!status.transport_status().capabilities().can_fetch());
+ assert!(!status.transport_status().capabilities().can_discover());
+ assert!(
+ !status
+ .transport_status()
+ .capabilities()
+ .can_forward_gateway()
+ );
+ assert!(
+ !status
+ .transport_status()
+ .capabilities()
+ .can_observe_receipts()
+ );
let receipt = transport
.deliver(delivery_request(vec![reticulum_target(
@@ -580,15 +596,12 @@ fn configured_agent_endpoint_is_metadata_only_for_status_delivery_and_fetch() {
.fetch(RadrootsReticulumFetchRequest::new("fetch-agent", 1).expect("fetch"))
.expect("fetch receipt");
assert_eq!(
- fetch
- .agent_endpoint
- .as_ref()
- .map(|endpoint| endpoint.as_str()),
+ fetch.agent_endpoint().map(|endpoint| endpoint.as_str()),
Some("reticulum-agent://localhost:19999")
);
- assert_eq!(fetch.observed_event_count, 0);
+ assert_eq!(fetch.observed_event_count(), 0);
assert_eq!(
- fetch.implementation,
+ fetch.implementation(),
RadrootsTransportImplementationState::Real
);
}
@@ -605,6 +618,103 @@ fn public_models_round_trip_through_serde() {
}
#[test]
+#[cfg(feature = "serde")]
+fn transport_bounds_reticulum_public_wire_is_strict_and_revalidated() {
+ let exact_request = RadrootsReticulumFetchRequest::new(
+ "r".repeat(RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES),
+ u16::try_from(RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT)
+ .expect("event maximum fits u16"),
+ )
+ .expect("exact fetch request");
+ assert_eq!(
+ exact_request.request_id().len(),
+ RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES
+ );
+ assert_eq!(
+ usize::from(exact_request.max_events()),
+ RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT
+ );
+ assert_eq!(
+ RadrootsReticulumFetchRequest::new(
+ "r".repeat(RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES + 1),
+ 1,
+ )
+ .expect_err("one-over request id"),
+ RadrootsReticulumError::InvalidFetchRequestId
+ );
+
+ let exact_agent = format!(
+ "reticulum-agent:{}",
+ "a".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES - "reticulum-agent:".len())
+ );
+ assert_eq!(
+ RadrootsReticulumAgentEndpoint::parse(exact_agent)
+ .expect("exact agent endpoint")
+ .as_str()
+ .len(),
+ RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES
+ );
+ assert_eq!(
+ RadrootsReticulumAgentEndpoint::parse(format!(
+ "reticulum-agent:{}",
+ "a".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES - "reticulum-agent:".len() + 1)
+ ))
+ .expect_err("one-over agent endpoint"),
+ RadrootsReticulumError::InvalidAgentEndpoint
+ );
+
+ let transport = RadrootsReticulumTransport::default();
+ let status = transport.status();
+ let receipt = transport
+ .fetch(RadrootsReticulumFetchRequest::new("fetch-wire", 1).expect("fetch request"))
+ .expect("fetch receipt");
+ let request_wire = serde_json::to_value(&exact_request).expect("request wire");
+ let status_wire = serde_json::to_value(&status).expect("status wire");
+ let receipt_wire = serde_json::to_value(&receipt).expect("receipt wire");
+
+ let mut request_over = request_wire.clone();
+ request_over["max_events"] =
+ serde_json::Value::from(RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT + 1);
+ assert!(serde_json::from_value::<RadrootsReticulumFetchRequest>(request_over).is_err());
+ let mut status_forged = status_wire.clone();
+ status_forged["capability_report"]["can_deliver"] = serde_json::Value::Bool(true);
+ assert!(
+ serde_json::from_value::<radroots_transport_reticulum::RadrootsReticulumStatus>(
+ status_forged
+ )
+ .is_err()
+ );
+ let mut receipt_forged = receipt_wire.clone();
+ receipt_forged["observed_event_count"] = serde_json::Value::from(1);
+ assert!(
+ serde_json::from_value::<radroots_transport_reticulum::RadrootsReticulumFetchReceipt>(
+ receipt_forged
+ )
+ .is_err()
+ );
+
+ let mut request_unknown = request_wire;
+ request_unknown["unexpected"] = serde_json::Value::Bool(true);
+ assert!(serde_json::from_value::<RadrootsReticulumFetchRequest>(request_unknown).is_err());
+ let mut status_unknown = status_wire;
+ status_unknown["unexpected"] = serde_json::Value::Bool(true);
+ assert!(
+ serde_json::from_value::<radroots_transport_reticulum::RadrootsReticulumStatus>(
+ status_unknown
+ )
+ .is_err()
+ );
+ let mut receipt_unknown = receipt_wire;
+ receipt_unknown["unexpected"] = serde_json::Value::Bool(true);
+ assert!(
+ serde_json::from_value::<radroots_transport_reticulum::RadrootsReticulumFetchReceipt>(
+ receipt_unknown
+ )
+ .is_err()
+ );
+}
+
+#[test]
fn reticulum_source_remains_inert_without_runtime_delivery_hooks() {
let source = include_str!("../src/lib.rs").to_ascii_lowercase();
for forbidden in [
@@ -644,7 +754,19 @@ fn reticulum_errors_and_defaults_are_stable() {
),
(
RadrootsReticulumError::InvalidFetchLimit,
- "Reticulum fetch limit must be greater than zero",
+ "Reticulum fetch limit must be between 1 and 1000",
+ ),
+ (
+ RadrootsReticulumError::InvalidFetchRequestId,
+ "invalid Reticulum fetch request id",
+ ),
+ (
+ RadrootsReticulumError::InvalidFetchReceipt,
+ "invalid Reticulum fetch receipt",
+ ),
+ (
+ RadrootsReticulumError::InvalidStatus,
+ "invalid Reticulum status",
),
(
RadrootsReticulumError::NonReticulumTarget,