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:
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");