lib

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

commit bd5b29f24a91084fde2aeddc1739e68cbb98a41a
parent 50a5087809405f4940f3d43ecc4dca0f057ddd7a
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 08:26:00 +0000

transport: seal relay diagnostic records

- make relay outcomes and failures private validated values
- canonicalize endpoint identity and bound every diagnostic field
- reject incoherent and unknown wire representations
- preserve one request-budgeted copy of closed relay messages

Diffstat:
Mcrates/transport_nostr/src/error.rs | 3+++
Mcrates/transport_nostr/src/fetch.rs | 268++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/transport_nostr/src/publish.rs | 1+
Mcrates/transport_nostr/tests/transport.rs | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
4 files changed, 376 insertions(+), 62 deletions(-)

diff --git a/crates/transport_nostr/src/error.rs b/crates/transport_nostr/src/error.rs @@ -44,6 +44,9 @@ pub enum RadrootsRelayTransportError { #[error("Relay fetch item contains invalid relay URL `{url}`: {reason}")] InvalidFetchItemRelayUrl { url: String, reason: String }, + #[error("Relay fetch receipt has invalid {field}: {reason}")] + InvalidFetchReceipt { field: &'static str, reason: String }, + #[error("Relay fetch item came from unrequested relay URL `{url}`")] UnexpectedFetchItemRelayUrl { url: String }, diff --git a/crates/transport_nostr/src/fetch.rs b/crates/transport_nostr/src/fetch.rs @@ -18,7 +18,7 @@ use radroots_transport::{ RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT, RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES, RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS, RadrootsTransportKind, RadrootsTransportTarget, }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex, PoisonError}; @@ -425,7 +425,7 @@ fn validate_fetch_item_relay_url(relay_url: String) -> Result<String, RadrootsRe Ok(relay_url) } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RadrootsRelayFetchOutcomeKind { Eose, Truncated, @@ -433,12 +433,154 @@ pub enum RadrootsRelayFetchOutcomeKind { Notice, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct RadrootsRelayFetchRelayOutcome { - pub relay_url: String, - pub kind: RadrootsRelayFetchOutcomeKind, - pub relay_outcome: Option<RadrootsRelayOutcome>, - pub message: Option<String>, + relay_url: String, + kind: RadrootsRelayFetchOutcomeKind, + relay_outcome: Option<RadrootsRelayOutcome>, + message: Option<String>, +} + +impl RadrootsRelayFetchRelayOutcome { + pub fn eose(relay_url: impl Into<String>) -> Result<Self, RadrootsRelayTransportError> { + Self::try_new( + relay_url.into(), + RadrootsRelayFetchOutcomeKind::Eose, + None, + None, + ) + } + + pub fn truncated( + relay_url: impl Into<String>, + message: impl Into<String>, + ) -> Result<Self, RadrootsRelayTransportError> { + Self::try_new( + relay_url.into(), + RadrootsRelayFetchOutcomeKind::Truncated, + None, + Some(message.into()), + ) + } + + pub fn closed( + relay_url: impl Into<String>, + message: impl Into<String>, + ) -> Result<Self, RadrootsRelayTransportError> { + let message = message.into(); + let classified = RadrootsRelayOutcome::classify(message.as_str())?; + let relay_outcome = RadrootsRelayOutcome::try_new(classified.kind(), None)?; + Self::try_new( + relay_url.into(), + RadrootsRelayFetchOutcomeKind::Closed, + Some(relay_outcome), + Some(message), + ) + } + + pub fn notice( + relay_url: impl Into<String>, + message: impl Into<String>, + ) -> Result<Self, RadrootsRelayTransportError> { + Self::try_new( + relay_url.into(), + RadrootsRelayFetchOutcomeKind::Notice, + None, + Some(message.into()), + ) + } + + fn try_new( + relay_url: String, + kind: RadrootsRelayFetchOutcomeKind, + relay_outcome: Option<RadrootsRelayOutcome>, + message: Option<String>, + ) -> Result<Self, RadrootsRelayTransportError> { + let relay_url = canonical_fetch_receipt_relay_url(relay_url.as_str())?; + if let Some(message) = message.as_deref() { + validate_fetch_receipt_diagnostic("relay_outcome_message", message)?; + } + match kind { + RadrootsRelayFetchOutcomeKind::Eose => { + if relay_outcome.is_some() || message.is_some() { + return Err(invalid_fetch_receipt( + "relay_outcome", + "EOSE cannot carry an outcome or message", + )); + } + } + RadrootsRelayFetchOutcomeKind::Truncated | RadrootsRelayFetchOutcomeKind::Notice => { + if relay_outcome.is_some() || message.is_none() { + return Err(invalid_fetch_receipt( + "relay_outcome", + "truncated and notice outcomes require a message and no relay outcome", + )); + } + } + RadrootsRelayFetchOutcomeKind::Closed => { + let Some(message) = message.as_deref() else { + return Err(invalid_fetch_receipt( + "relay_outcome", + "closed outcomes require a message and classified relay outcome", + )); + }; + let expected = RadrootsRelayOutcome::classify(message)?; + if relay_outcome.as_ref().map(RadrootsRelayOutcome::kind) != Some(expected.kind()) + || relay_outcome + .as_ref() + .and_then(RadrootsRelayOutcome::message) + .is_some() + { + return Err(invalid_fetch_receipt( + "relay_outcome", + "closed outcome classification must match its message without duplicating it", + )); + } + } + } + Ok(Self { + relay_url, + kind, + relay_outcome, + message, + }) + } + + pub fn relay_url(&self) -> &str { + self.relay_url.as_str() + } + + pub fn kind(&self) -> RadrootsRelayFetchOutcomeKind { + self.kind + } + + pub fn relay_outcome(&self) -> Option<&RadrootsRelayOutcome> { + self.relay_outcome.as_ref() + } + + pub fn message(&self) -> Option<&str> { + self.message.as_deref() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RadrootsRelayFetchRelayOutcomeWire { + relay_url: String, + kind: RadrootsRelayFetchOutcomeKind, + relay_outcome: Option<RadrootsRelayOutcome>, + message: Option<String>, +} + +impl<'de> Deserialize<'de> for RadrootsRelayFetchRelayOutcome { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let wire = RadrootsRelayFetchRelayOutcomeWire::deserialize(deserializer)?; + Self::try_new(wire.relay_url, wire.kind, wire.relay_outcome, wire.message) + .map_err(de::Error::custom) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -503,10 +645,80 @@ pub struct RadrootsRelayFetchedEvent { pub observed_at_ms: i64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct RadrootsRelayFetchFailure { - pub relay_url: String, - pub reason: String, + relay_url: String, + reason: String, +} + +impl RadrootsRelayFetchFailure { + pub fn new( + relay_url: impl Into<String>, + reason: impl Into<String>, + ) -> Result<Self, RadrootsRelayTransportError> { + let relay_url = relay_url.into(); + let reason = reason.into(); + let relay_url = canonical_fetch_receipt_relay_url(relay_url.as_str())?; + validate_fetch_receipt_diagnostic("relay_failure_reason", reason.as_str())?; + Ok(Self { relay_url, reason }) + } + + pub fn relay_url(&self) -> &str { + self.relay_url.as_str() + } + + pub fn reason(&self) -> &str { + self.reason.as_str() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RadrootsRelayFetchFailureWire { + relay_url: String, + reason: String, +} + +impl<'de> Deserialize<'de> for RadrootsRelayFetchFailure { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let wire = RadrootsRelayFetchFailureWire::deserialize(deserializer)?; + Self::new(wire.relay_url, wire.reason).map_err(de::Error::custom) + } +} + +fn canonical_fetch_receipt_relay_url( + relay_url: &str, +) -> Result<String, RadrootsRelayTransportError> { + RadrootsTransportTarget::nostr_relay(relay_url) + .map(|target| target.uri().as_str().to_owned()) + .map_err(|error| invalid_fetch_receipt("relay_url", error.to_string())) +} + +fn validate_fetch_receipt_diagnostic( + field: &'static str, + value: &str, +) -> Result<(), RadrootsRelayTransportError> { + if value.len() > radroots_transport::RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES { + return Err(RadrootsRelayTransportError::DiagnosticLimitExceeded { + field, + max: radroots_transport::RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + actual: value.len(), + }); + } + Ok(()) +} + +fn invalid_fetch_receipt( + field: &'static str, + reason: impl Into<String>, +) -> RadrootsRelayTransportError { + RadrootsRelayTransportError::InvalidFetchReceipt { + field, + reason: reason.into(), + } } #[derive(Clone, Debug)] @@ -808,13 +1020,13 @@ impl RadrootsRelayProcessedFetch { let connected_relays = self .relay_outcomes .iter() - .filter(|outcome| outcome.kind == RadrootsRelayFetchOutcomeKind::Eose) - .map(|outcome| outcome.relay_url.clone()) + .filter(|outcome| outcome.kind() == RadrootsRelayFetchOutcomeKind::Eose) + .map(|outcome| outcome.relay_url().to_owned()) .collect(); let failed_relays = self .relay_outcomes .iter() - .filter(|outcome| outcome.kind == RadrootsRelayFetchOutcomeKind::Closed) + .filter(|outcome| outcome.kind() == RadrootsRelayFetchOutcomeKind::Closed) .map(|outcome| RadrootsRelayFetchFailure { relay_url: outcome.relay_url.clone(), reason: outcome.message.clone().unwrap_or_default(), @@ -1229,45 +1441,27 @@ fn process_relay_fetch_items( processed.eose_count += 1; processed .relay_outcomes - .push(RadrootsRelayFetchRelayOutcome { - relay_url, - kind: RadrootsRelayFetchOutcomeKind::Eose, - relay_outcome: None, - message: None, - }); + .push(RadrootsRelayFetchRelayOutcome::eose(relay_url)?); } RadrootsRelayFetchItemBody::Truncated { message, .. } => { processed.truncated_count += 1; processed .relay_outcomes - .push(RadrootsRelayFetchRelayOutcome { - relay_url, - kind: RadrootsRelayFetchOutcomeKind::Truncated, - relay_outcome: None, - message: Some(message), - }); + .push(RadrootsRelayFetchRelayOutcome::truncated( + relay_url, message, + )?); } RadrootsRelayFetchItemBody::Closed { message, .. } => { processed.closed_count += 1; processed .relay_outcomes - .push(RadrootsRelayFetchRelayOutcome { - relay_url, - kind: RadrootsRelayFetchOutcomeKind::Closed, - relay_outcome: Some(RadrootsRelayOutcome::classify(message.as_str())?), - message: Some(message), - }); + .push(RadrootsRelayFetchRelayOutcome::closed(relay_url, message)?); } RadrootsRelayFetchItemBody::Notice { message, .. } => { processed.notice_count += 1; processed .relay_outcomes - .push(RadrootsRelayFetchRelayOutcome { - relay_url, - kind: RadrootsRelayFetchOutcomeKind::Notice, - relay_outcome: None, - message: Some(message), - }); + .push(RadrootsRelayFetchRelayOutcome::notice(relay_url, message)?); } } } diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -673,6 +673,7 @@ fn nostr_error_to_transport_error(error: RadrootsRelayTransportError) -> Radroot RadrootsRelayTransportError::EmptyFetchFilters | RadrootsRelayTransportError::InvalidFetchLimit { .. } | RadrootsRelayTransportError::FetchLimitTooLarge { .. } + | RadrootsRelayTransportError::InvalidFetchReceipt { .. } | RadrootsRelayTransportError::InvalidPublishReceipt { .. } | RadrootsRelayTransportError::InvalidTimestamp { .. } | RadrootsRelayTransportError::InvalidIdempotencyKey { .. } diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -37,14 +37,14 @@ use radroots_transport_nostr::{ RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, RadrootsOutboxPublishPolicy, RadrootsRelayFetchEventAdmission, RadrootsRelayFetchEventValidStream, RadrootsRelayFetchEventVerification, RadrootsRelayFetchEventVisibility, - RadrootsRelayFetchFilters, RadrootsRelayFetchItem, RadrootsRelayFetchMode, - RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchRequest, RadrootsRelayOutcome, - RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishRelayReceipt, - RadrootsRelayPublishRequest, RadrootsRelayTargetSet, RadrootsRelayTransportError, - RadrootsRelayUrl, RadrootsRelayUrlPolicy, fetch_and_ingest_relay_events, fetch_relay_events, - fetch_relay_events_blocking, publish_claimed_outbox_event, - publish_claimed_outbox_event_with_transport, publish_signed_event, - verified_signed_event_payload, + RadrootsRelayFetchFailure, RadrootsRelayFetchFilters, RadrootsRelayFetchItem, + RadrootsRelayFetchMode, RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchRelayOutcome, + RadrootsRelayFetchRequest, RadrootsRelayOutcome, RadrootsRelayOutcomeKind, + RadrootsRelayPublishAdapter, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, + RadrootsRelayTargetSet, RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy, + fetch_and_ingest_relay_events, fetch_relay_events, fetch_relay_events_blocking, + publish_claimed_outbox_event, publish_claimed_outbox_event_with_transport, + publish_signed_event, verified_signed_event_payload, }; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -2499,7 +2499,7 @@ async fn fetch_reports_local_truncation_without_claiming_eose() { assert_eq!(receipt.truncated_count, 1); assert_eq!(receipt.relay_outcomes.len(), 1); assert_eq!( - receipt.relay_outcomes[0].kind, + receipt.relay_outcomes[0].kind(), RadrootsRelayFetchOutcomeKind::Truncated ); } @@ -2675,35 +2675,33 @@ async fn fetch_ingests_events_and_records_transport_observations() { || event.verification == RadrootsRelayFetchEventVerification::NotEvaluated) })); assert_eq!(receipt.relay_outcomes.len(), 4); - assert_eq!(receipt.relay_outcomes[0].relay_url, RELAY_PRIMARY_WSS); + assert_eq!(receipt.relay_outcomes[0].relay_url(), RELAY_PRIMARY_WSS); assert_eq!( - receipt.relay_outcomes[0].kind, + receipt.relay_outcomes[0].kind(), RadrootsRelayFetchOutcomeKind::Eose ); - assert!(receipt.relay_outcomes[0].relay_outcome.is_none()); - assert_eq!(receipt.relay_outcomes[1].relay_url, RELAY_SECONDARY_WSS); + assert!(receipt.relay_outcomes[0].relay_outcome().is_none()); + assert_eq!(receipt.relay_outcomes[1].relay_url(), RELAY_SECONDARY_WSS); assert_eq!( receipt.relay_outcomes[1] - .relay_outcome - .as_ref() + .relay_outcome() .expect("auth outcome") .kind(), RadrootsRelayOutcomeKind::AuthRequired ); - assert_eq!(receipt.relay_outcomes[2].relay_url, RELAY_TERTIARY_WSS); + assert_eq!(receipt.relay_outcomes[2].relay_url(), RELAY_TERTIARY_WSS); assert_eq!( receipt.relay_outcomes[2] - .relay_outcome - .as_ref() + .relay_outcome() .expect("restricted outcome") .kind(), RadrootsRelayOutcomeKind::Restricted ); assert_eq!( - receipt.relay_outcomes[3].kind, + receipt.relay_outcomes[3].kind(), RadrootsRelayFetchOutcomeKind::Notice ); - assert!(receipt.relay_outcomes[3].relay_outcome.is_none()); + assert!(receipt.relay_outcomes[3].relay_outcome().is_none()); assert_eq!( receipt.events[0].admission, RadrootsRelayFetchEventAdmission::Admitted @@ -3223,19 +3221,18 @@ async fn fetch_event_cap_counts_accepted_in_filter_events_and_preserves_later_co assert_eq!(receipt.notice_count, 1); assert_eq!(receipt.relay_outcomes.len(), 3); assert_eq!( - receipt.relay_outcomes[0].kind, + receipt.relay_outcomes[0].kind(), RadrootsRelayFetchOutcomeKind::Eose ); assert_eq!( receipt.relay_outcomes[1] - .relay_outcome - .as_ref() + .relay_outcome() .expect("closed outcome") .kind(), RadrootsRelayOutcomeKind::AuthRequired ); assert_eq!( - receipt.relay_outcomes[2].kind, + receipt.relay_outcomes[2].kind(), RadrootsRelayFetchOutcomeKind::Notice ); assert!( @@ -3311,7 +3308,7 @@ async fn fetch_relay_events_applies_shared_filter_limit_and_outcome_evidence() { ); assert_eq!(receipt.connected_relays, vec![RELAY_PRIMARY_WSS]); assert_eq!(receipt.failed_relays.len(), 1); - assert_eq!(receipt.failed_relays[0].relay_url, RELAY_SECONDARY_WSS); + assert_eq!(receipt.failed_relays[0].relay_url(), RELAY_SECONDARY_WSS); assert_eq!(receipt.events.len(), 1); assert_eq!(receipt.events[0].event.id.to_hex(), accepted_id); assert_eq!(receipt.malformed_count, 1); @@ -3605,6 +3602,125 @@ fn fetch_items_reject_invalid_endpoints_and_oversized_diagnostics() { } } +#[test] +fn fetch_relay_outcomes_and_failures_seal_state_and_strict_wire() { + let eose = + RadrootsRelayFetchRelayOutcome::eose(RELAY_PRIMARY_WSS).expect("bounded EOSE outcome"); + assert_eq!(eose.relay_url(), RELAY_PRIMARY_WSS); + assert_eq!(eose.kind(), RadrootsRelayFetchOutcomeKind::Eose); + assert!(eose.relay_outcome().is_none()); + assert!(eose.message().is_none()); + + let exact = "x".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES); + let closed = RadrootsRelayFetchRelayOutcome::closed(RELAY_SECONDARY_WSS, exact.clone()) + .expect("exact-limit closed outcome"); + assert_eq!(closed.message(), Some(exact.as_str())); + assert_eq!( + closed.relay_outcome().map(RadrootsRelayOutcome::kind), + Some(RadrootsRelayOutcomeKind::Unknown) + ); + assert_eq!( + closed + .relay_outcome() + .and_then(RadrootsRelayOutcome::message), + None + ); + let closed_wire = serde_json::to_value(&closed).expect("closed outcome wire"); + assert_eq!( + serde_json::from_value::<RadrootsRelayFetchRelayOutcome>(closed_wire.clone()) + .expect("strict closed outcome reload"), + closed + ); + + for outcome in [ + RadrootsRelayFetchRelayOutcome::truncated( + RELAY_PRIMARY_WSS, + "x".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1), + ), + RadrootsRelayFetchRelayOutcome::closed( + RELAY_PRIMARY_WSS, + "x".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1), + ), + RadrootsRelayFetchRelayOutcome::notice( + RELAY_PRIMARY_WSS, + "x".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1), + ), + ] { + assert!(matches!( + outcome, + Err(RadrootsRelayTransportError::DiagnosticLimitExceeded { + max: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + actual, + .. + }) if actual == RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1 + )); + } + assert!(matches!( + RadrootsRelayFetchRelayOutcome::eose(" "), + Err(RadrootsRelayTransportError::InvalidFetchReceipt { + field: "relay_url", + .. + }) + )); + let prefix = "wss://relay.example.com/"; + let exact_url = format!( + "{prefix}{}", + "x".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES - prefix.len()) + ); + assert!(RadrootsRelayFetchRelayOutcome::eose(exact_url.clone()).is_ok()); + assert!(matches!( + RadrootsRelayFetchFailure::new(format!("{exact_url}x"), "failed"), + Err(RadrootsRelayTransportError::InvalidFetchReceipt { + field: "relay_url", + .. + }) + )); + + let mut incoherent_eose = serde_json::to_value(&eose).expect("EOSE outcome wire"); + incoherent_eose["message"] = serde_json::json!("unexpected"); + assert!(serde_json::from_value::<RadrootsRelayFetchRelayOutcome>(incoherent_eose).is_err()); + let mut incoherent_closed = closed_wire.clone(); + incoherent_closed["relay_outcome"] = serde_json::json!(null); + assert!(serde_json::from_value::<RadrootsRelayFetchRelayOutcome>(incoherent_closed).is_err()); + let mut mismatched_closed = closed_wire; + mismatched_closed["relay_outcome"] = serde_json::json!({ + "kind": "Accepted", + "message": null + }); + assert!(serde_json::from_value::<RadrootsRelayFetchRelayOutcome>(mismatched_closed).is_err()); + let mut duplicated_closed = serde_json::to_value(&closed).expect("closed outcome wire"); + duplicated_closed["relay_outcome"]["message"] = serde_json::json!(exact); + assert!(serde_json::from_value::<RadrootsRelayFetchRelayOutcome>(duplicated_closed).is_err()); + let mut unknown_outcome = serde_json::to_value(&eose).expect("EOSE outcome wire"); + unknown_outcome["extra"] = serde_json::json!(true); + assert!(serde_json::from_value::<RadrootsRelayFetchRelayOutcome>(unknown_outcome).is_err()); + + let failure = RadrootsRelayFetchFailure::new(RELAY_PRIMARY_WSS, exact.clone()) + .expect("exact-limit fetch failure"); + assert_eq!(failure.relay_url(), RELAY_PRIMARY_WSS); + assert_eq!(failure.reason(), exact); + let failure_wire = serde_json::to_value(&failure).expect("fetch failure wire"); + assert_eq!( + serde_json::from_value::<RadrootsRelayFetchFailure>(failure_wire.clone()) + .expect("strict fetch failure reload"), + failure + ); + assert!(matches!( + RadrootsRelayFetchFailure::new( + RELAY_PRIMARY_WSS, + "x".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1) + ), + Err(RadrootsRelayTransportError::DiagnosticLimitExceeded { + field: "relay_failure_reason", + max: RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES, + actual, + }) if actual == RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1 + )); + let mut unknown_failure = failure_wire; + unknown_failure["extra"] = serde_json::json!(true); + assert!(serde_json::from_value::<RadrootsRelayFetchFailure>(unknown_failure).is_err()); +} + #[tokio::test] async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() { let outbox = RadrootsOutbox::open_memory().await.expect("outbox");