lib

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

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:
Acontracts/conformance/vectors/transport/resource_limits.v1.json | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/outbox/contracts/migration_authority_v1.manifest.json | 4++--
Mcrates/outbox/contracts/migration_authority_v1.manifest.sha256 | 2+-
Mcrates/runtime/src/transport.rs | 26++++++++++++++------------
Mcrates/transport/src/reticulum.rs | 227++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/transport/src/status.rs | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/transport/tests/transport.rs | 452+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/transport_nostr/src/outbox.rs | 139++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/transport_nostr/src/publish.rs | 1+
Mcrates/transport_nostr/tests/transport.rs | 321+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mcrates/transport_reticulum/src/lib.rs | 543++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/transport_reticulum/tests/reticulum.rs | 286++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
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,