lib

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

commit 09caf2c6dfab061b1f7d63b30d0d883b7cce4b96
parent bd5b29f24a91084fde2aeddc1739e68cbb98a41a
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 08:31:28 +0000

transport: seal relay event receipts

- make per-event receipt state private and observable through accessors
- validate event identity disposition and semantic state coherence
- bound admission codes and request diagnostic contributions
- reject oversized mutated and unknown strict wire representations

Diffstat:
Mcrates/transport_nostr/src/fetch.rs | 330+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/transport_nostr/tests/transport.rs | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
2 files changed, 469 insertions(+), 120 deletions(-)

diff --git a/crates/transport_nostr/src/fetch.rs b/crates/transport_nostr/src/fetch.rs @@ -5,7 +5,7 @@ use crate::{RadrootsRelayOutcome, RadrootsRelayTargetSet, RadrootsRelayTransport use core::time::Duration; use futures::{StreamExt, future::BoxFuture}; use nostr::{JsonUtil, filter::MatchEventOptions}; -use radroots_event::wire::v1::DEFAULT_RAW_JSON_MAX_BYTES; +use radroots_event::{ids::RadrootsEventId, wire::v1::DEFAULT_RAW_JSON_MAX_BYTES}; use radroots_event_store::{ RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventPersistence, RadrootsEventStore, RadrootsEventVisibility, RadrootsTransportObservation, @@ -619,22 +619,242 @@ pub enum RadrootsRelayFetchEventVisibility { Suppressed, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct RadrootsRelayFetchEventReceipt { - pub relay_url: String, - pub event_id: Option<String>, - pub inserted: bool, - pub duplicate: bool, - pub not_persisted: bool, - pub malformed: bool, - pub out_of_filter: bool, - pub skipped_over_limit: bool, - pub verification: RadrootsRelayFetchEventVerification, - pub admission: RadrootsRelayFetchEventAdmission, - pub admission_code: Option<String>, - pub valid_stream: RadrootsRelayFetchEventValidStream, - pub visibility: RadrootsRelayFetchEventVisibility, - pub message: Option<String>, + relay_url: String, + event_id: Option<String>, + inserted: bool, + duplicate: bool, + not_persisted: bool, + malformed: bool, + out_of_filter: bool, + skipped_over_limit: bool, + verification: RadrootsRelayFetchEventVerification, + admission: RadrootsRelayFetchEventAdmission, + admission_code: Option<String>, + valid_stream: RadrootsRelayFetchEventValidStream, + visibility: RadrootsRelayFetchEventVisibility, + message: Option<String>, +} + +impl RadrootsRelayFetchEventReceipt { + fn checked(mut self) -> Result<Self, RadrootsRelayTransportError> { + self.relay_url = canonical_fetch_receipt_relay_url(self.relay_url.as_str())?; + if let Some(event_id) = self.event_id.as_deref() { + RadrootsEventId::parse(event_id) + .map_err(|error| invalid_fetch_receipt("event_id", error.to_string()))?; + } + if let Some(admission_code) = self.admission_code.as_deref() + && admission_code.len() > radroots_transport::RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES + { + return Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "admission_code_bytes", + max: radroots_transport::RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES, + actual: admission_code.len(), + }); + } + if let Some(message) = self.message.as_deref() { + validate_fetch_receipt_diagnostic("event_receipt_message", message)?; + } + + let disposition_count = usize::from(self.inserted) + + usize::from(self.duplicate) + + usize::from(self.not_persisted) + + usize::from(self.malformed) + + usize::from(self.out_of_filter) + + usize::from(self.skipped_over_limit); + if disposition_count > 1 { + return Err(invalid_fetch_receipt( + "event_disposition", + "event receipt dispositions are mutually exclusive", + )); + } + if (self.inserted + || self.duplicate + || self.not_persisted + || self.out_of_filter + || self.skipped_over_limit) + && self.event_id.is_none() + { + return Err(invalid_fetch_receipt( + "event_id", + "this event receipt disposition requires an event id", + )); + } + if self.malformed + && (self.event_id.is_some() + || self.verification != RadrootsRelayFetchEventVerification::NotEvaluated) + { + return Err(invalid_fetch_receipt( + "malformed", + "malformed receipts cannot identify or verify an event", + )); + } + if (self.inserted + || self.duplicate + || self.not_persisted + || self.out_of_filter + || self.skipped_over_limit) + && self.verification != RadrootsRelayFetchEventVerification::Verified + { + return Err(invalid_fetch_receipt( + "verification", + "this event receipt disposition requires verified event bytes", + )); + } + if self.verification != RadrootsRelayFetchEventVerification::Verified + && (self.admission != RadrootsRelayFetchEventAdmission::NotEvaluated + || self.admission_code.is_some() + || self.valid_stream != RadrootsRelayFetchEventValidStream::NotEvaluated + || self.visibility != RadrootsRelayFetchEventVisibility::NotEvaluated) + { + return Err(invalid_fetch_receipt( + "verification", + "unverified event receipts cannot carry semantic outcomes", + )); + } + if self.admission == RadrootsRelayFetchEventAdmission::NotEvaluated + && (self.admission_code.is_some() + || self.valid_stream != RadrootsRelayFetchEventValidStream::NotEvaluated) + { + return Err(invalid_fetch_receipt( + "admission", + "unevaluated admission cannot carry a code or valid-stream result", + )); + } + if self.admission != RadrootsRelayFetchEventAdmission::NotEvaluated + && self.valid_stream == RadrootsRelayFetchEventValidStream::NotEvaluated + { + return Err(invalid_fetch_receipt( + "valid_stream", + "evaluated admission requires a valid-stream result", + )); + } + if self.not_persisted + != (self.visibility == RadrootsRelayFetchEventVisibility::NotPersisted) + { + return Err(invalid_fetch_receipt( + "visibility", + "not-persisted disposition and visibility must agree", + )); + } + if matches!( + self.visibility, + RadrootsRelayFetchEventVisibility::Visible + | RadrootsRelayFetchEventVisibility::NotAdmitted + | RadrootsRelayFetchEventVisibility::NotCurrent + | RadrootsRelayFetchEventVisibility::Suppressed + ) && !(self.inserted || self.duplicate) + { + return Err(invalid_fetch_receipt( + "visibility", + "stored visibility requires an inserted or duplicate receipt", + )); + } + Ok(self) + } + + pub fn relay_url(&self) -> &str { + self.relay_url.as_str() + } + + pub fn event_id(&self) -> Option<&str> { + self.event_id.as_deref() + } + + pub fn was_inserted(&self) -> bool { + self.inserted + } + + pub fn was_duplicate(&self) -> bool { + self.duplicate + } + + pub fn was_not_persisted(&self) -> bool { + self.not_persisted + } + + pub fn is_malformed(&self) -> bool { + self.malformed + } + + pub fn is_out_of_filter(&self) -> bool { + self.out_of_filter + } + + pub fn was_skipped_over_limit(&self) -> bool { + self.skipped_over_limit + } + + pub fn verification(&self) -> RadrootsRelayFetchEventVerification { + self.verification + } + + pub fn admission(&self) -> RadrootsRelayFetchEventAdmission { + self.admission + } + + pub fn admission_code(&self) -> Option<&str> { + self.admission_code.as_deref() + } + + pub fn valid_stream(&self) -> RadrootsRelayFetchEventValidStream { + self.valid_stream + } + + pub fn visibility(&self) -> RadrootsRelayFetchEventVisibility { + self.visibility + } + + pub fn message(&self) -> Option<&str> { + self.message.as_deref() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RadrootsRelayFetchEventReceiptWire { + relay_url: String, + event_id: Option<String>, + inserted: bool, + duplicate: bool, + not_persisted: bool, + malformed: bool, + out_of_filter: bool, + skipped_over_limit: bool, + verification: RadrootsRelayFetchEventVerification, + admission: RadrootsRelayFetchEventAdmission, + admission_code: Option<String>, + valid_stream: RadrootsRelayFetchEventValidStream, + visibility: RadrootsRelayFetchEventVisibility, + message: Option<String>, +} + +impl<'de> Deserialize<'de> for RadrootsRelayFetchEventReceipt { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let wire = RadrootsRelayFetchEventReceiptWire::deserialize(deserializer)?; + Self { + relay_url: wire.relay_url, + event_id: wire.event_id, + inserted: wire.inserted, + duplicate: wire.duplicate, + not_persisted: wire.not_persisted, + malformed: wire.malformed, + out_of_filter: wire.out_of_filter, + skipped_over_limit: wire.skipped_over_limit, + verification: wire.verification, + admission: wire.admission, + admission_code: wire.admission_code, + valid_stream: wire.valid_stream, + visibility: wire.visibility, + message: wire.message, + } + .checked() + .map_err(de::Error::custom) + } } #[derive(Clone, Debug)] @@ -785,7 +1005,7 @@ where let max_raw_json_bytes = request.max_raw_json_bytes; let filters = request.filters.as_slice().to_vec(); let items = adapter.fetch(request).await?; - Ok(process_relay_fetch_items( + process_relay_fetch_items( target_relays, filters, observed_at_ms, @@ -794,7 +1014,7 @@ where max_raw_json_bytes, items, )? - .into_fetched_events_receipt()) + .into_fetched_events_receipt() } #[cfg(feature = "runtime-tokio")] @@ -872,22 +1092,25 @@ where Ok(ingest) => ingest.with_observation(observation), Err(error) => { receipt.verification_failed_count += 1; - receipt.events.push(RadrootsRelayFetchEventReceipt { - relay_url, - event_id: Some(raw_event.id.to_hex()), - inserted: false, - duplicate: false, - not_persisted: false, - malformed: false, - out_of_filter: false, - skipped_over_limit: false, - verification: RadrootsRelayFetchEventVerification::Failed, - admission: RadrootsRelayFetchEventAdmission::NotEvaluated, - admission_code: None, - valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, - visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, - message: Some(error.to_string()), - }); + receipt.events.push( + RadrootsRelayFetchEventReceipt { + relay_url, + event_id: Some(raw_event.id.to_hex()), + inserted: false, + duplicate: false, + not_persisted: false, + malformed: false, + out_of_filter: false, + skipped_over_limit: false, + verification: RadrootsRelayFetchEventVerification::Failed, + admission: RadrootsRelayFetchEventAdmission::NotEvaluated, + admission_code: None, + valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, + visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, + message: Some(error.to_string()), + } + .checked()?, + ); continue; } }; @@ -932,12 +1155,16 @@ where valid_stream, visibility, message: None, - }; + } + .checked()?; receipt.events.push(event_receipt); } } } receipt.refresh_final_semantic_outcomes(event_store).await?; + for event in &receipt.events { + event.clone().checked()?; + } Ok(receipt) } @@ -1002,17 +1229,19 @@ struct RadrootsRelayProcessedFetch { } impl RadrootsRelayProcessedFetch { - fn into_fetched_events_receipt(self) -> RadrootsRelayFetchedEventsReceipt { + fn into_fetched_events_receipt( + self, + ) -> Result<RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError> { let mut events = Vec::new(); let mut event_receipts = Vec::new(); for item in self.items { match item { RadrootsRelayProcessedFetchItem::Accepted(event) => { - event_receipts.push(accepted_fetch_event_receipt(&event)); + event_receipts.push(accepted_fetch_event_receipt(&event)?); events.push(event); } RadrootsRelayProcessedFetchItem::Duplicate(event) => { - event_receipts.push(duplicate_fetch_event_receipt(&event)); + event_receipts.push(duplicate_fetch_event_receipt(&event)?); } RadrootsRelayProcessedFetchItem::Receipt(receipt) => event_receipts.push(receipt), } @@ -1032,7 +1261,7 @@ impl RadrootsRelayProcessedFetch { reason: outcome.message.clone().unwrap_or_default(), }) .collect(); - RadrootsRelayFetchedEventsReceipt { + Ok(RadrootsRelayFetchedEventsReceipt { target_relays: self.target_relays, connected_relays, failed_relays, @@ -1048,7 +1277,7 @@ impl RadrootsRelayProcessedFetch { closed_count: self.closed_count, notice_count: self.notice_count, relay_outcomes: self.relay_outcomes, - } + }) } } @@ -1305,7 +1534,8 @@ fn process_relay_fetch_items( message: Some(format!( "event raw JSON exceeds {DEFAULT_RAW_JSON_MAX_BYTES} byte limit" )), - }, + } + .checked()?, )); continue; } @@ -1330,7 +1560,8 @@ fn process_relay_fetch_items( valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some("event JSON parse failed".to_owned()), - }, + } + .checked()?, )); continue; }; @@ -1356,7 +1587,8 @@ fn process_relay_fetch_items( valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some(error.to_string()), - }, + } + .checked()?, )); continue; } @@ -1380,7 +1612,8 @@ fn process_relay_fetch_items( valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some("event did not match relay fetch filters".to_owned()), - }, + } + .checked()?, )); continue; } @@ -1421,7 +1654,8 @@ fn process_relay_fetch_items( message: Some( "accepted relay fetch event limit reached".to_owned(), ), - }, + } + .checked()?, )); continue; } @@ -1489,7 +1723,7 @@ fn canonical_requested_fetch_relay( fn accepted_fetch_event_receipt( event: &RadrootsRelayFetchedEvent, -) -> RadrootsRelayFetchEventReceipt { +) -> Result<RadrootsRelayFetchEventReceipt, RadrootsRelayTransportError> { RadrootsRelayFetchEventReceipt { relay_url: event.relay_url.clone(), event_id: Some(event.event.id.to_hex()), @@ -1506,11 +1740,12 @@ fn accepted_fetch_event_receipt( visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some("event accepted by relay fetch filters".to_owned()), } + .checked() } fn duplicate_fetch_event_receipt( event: &RadrootsRelayFetchedEvent, -) -> RadrootsRelayFetchEventReceipt { +) -> Result<RadrootsRelayFetchEventReceipt, RadrootsRelayTransportError> { RadrootsRelayFetchEventReceipt { relay_url: event.relay_url.clone(), event_id: Some(event.event.id.to_hex()), @@ -1527,6 +1762,7 @@ fn duplicate_fetch_event_receipt( visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some("event ID was already observed in this relay fetch".to_owned()), } + .checked() } #[cfg_attr(coverage_nightly, coverage(off))] diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -2439,11 +2439,11 @@ async fn fetch_verifies_events_before_acceptance_budgeting() { assert_eq!(receipt.verification_failed_count, 1); assert_eq!(receipt.skipped_over_limit_count, 0); assert_eq!( - receipt.event_receipts[0].verification, + receipt.event_receipts[0].verification(), RadrootsRelayFetchEventVerification::Failed ); assert_eq!( - receipt.event_receipts[1].verification, + receipt.event_receipts[1].verification(), RadrootsRelayFetchEventVerification::Verified ); } @@ -2476,10 +2476,10 @@ async fn fetch_deduplicates_event_ids_without_starving_unique_events() { assert_eq!(receipt.skipped_over_limit_count, 0); assert_eq!(receipt.event_receipts.len(), 3); assert_eq!( - receipt.event_receipts[1].event_id.as_deref(), + receipt.event_receipts[1].event_id(), Some(first_id.as_str()) ); - assert!(receipt.event_receipts[1].duplicate); + assert!(receipt.event_receipts[1].was_duplicate()); } #[tokio::test] @@ -2616,14 +2616,18 @@ async fn fetch_ingests_events_and_records_transport_observations() { assert_eq!(receipt.notice_count, 1); assert_eq!( receipt.inserted_count, - receipt.events.iter().filter(|event| event.inserted).count() + receipt + .events + .iter() + .filter(|event| event.was_inserted()) + .count() ); assert_eq!( receipt.duplicate_count, receipt .events .iter() - .filter(|event| event.duplicate) + .filter(|event| event.was_duplicate()) .count() ); assert_eq!( @@ -2631,7 +2635,7 @@ async fn fetch_ingests_events_and_records_transport_observations() { receipt .events .iter() - .filter(|event| event.not_persisted) + .filter(|event| event.was_not_persisted()) .count() ); assert_eq!( @@ -2639,7 +2643,7 @@ async fn fetch_ingests_events_and_records_transport_observations() { receipt .events .iter() - .filter(|event| event.admission == RadrootsRelayFetchEventAdmission::Unsupported) + .filter(|event| event.admission() == RadrootsRelayFetchEventAdmission::Unsupported) .count() ); assert_eq!( @@ -2647,7 +2651,7 @@ async fn fetch_ingests_events_and_records_transport_observations() { receipt .events .iter() - .filter(|event| event.admission == RadrootsRelayFetchEventAdmission::Invalid) + .filter(|event| event.admission() == RadrootsRelayFetchEventAdmission::Invalid) .count() ); assert_eq!( @@ -2655,7 +2659,7 @@ async fn fetch_ingests_events_and_records_transport_observations() { receipt .events .iter() - .filter(|event| event.verification == RadrootsRelayFetchEventVerification::Failed) + .filter(|event| event.verification() == RadrootsRelayFetchEventVerification::Failed) .count() ); assert_eq!( @@ -2663,16 +2667,16 @@ async fn fetch_ingests_events_and_records_transport_observations() { receipt .events .iter() - .filter(|event| event.malformed) + .filter(|event| event.is_malformed()) .count() ); assert!(receipt.events.iter().all(|event| { - usize::from(event.inserted) - + usize::from(event.duplicate) - + usize::from(event.not_persisted) + usize::from(event.was_inserted()) + + usize::from(event.was_duplicate()) + + usize::from(event.was_not_persisted()) <= 1 - && (!event.malformed - || event.verification == RadrootsRelayFetchEventVerification::NotEvaluated) + && (!event.is_malformed() + || event.verification() == RadrootsRelayFetchEventVerification::NotEvaluated) })); assert_eq!(receipt.relay_outcomes.len(), 4); assert_eq!(receipt.relay_outcomes[0].relay_url(), RELAY_PRIMARY_WSS); @@ -2703,75 +2707,72 @@ async fn fetch_ingests_events_and_records_transport_observations() { ); assert!(receipt.relay_outcomes[3].relay_outcome().is_none()); assert_eq!( - receipt.events[0].admission, + receipt.events[0].admission(), RadrootsRelayFetchEventAdmission::Admitted ); assert_eq!( - receipt.events[0].valid_stream, + receipt.events[0].valid_stream(), RadrootsRelayFetchEventValidStream::Eligible ); assert_eq!( - receipt.events[0].visibility, + receipt.events[0].visibility(), RadrootsRelayFetchEventVisibility::Visible ); assert_eq!( - receipt.events[1].admission, + receipt.events[1].admission(), RadrootsRelayFetchEventAdmission::Admitted ); assert_eq!( - receipt.events[1].valid_stream, + receipt.events[1].valid_stream(), RadrootsRelayFetchEventValidStream::Eligible ); assert_eq!( - receipt.events[1].visibility, + receipt.events[1].visibility(), RadrootsRelayFetchEventVisibility::Visible ); assert_eq!( - receipt.events[2].admission, + receipt.events[2].admission(), RadrootsRelayFetchEventAdmission::Unsupported ); + assert_eq!(receipt.events[2].admission_code(), Some("unsupported_kind")); assert_eq!( - receipt.events[2].admission_code.as_deref(), - Some("unsupported_kind") - ); - assert_eq!( - receipt.events[2].valid_stream, + receipt.events[2].valid_stream(), RadrootsRelayFetchEventValidStream::Ineligible ); assert_eq!( - receipt.events[2].visibility, + receipt.events[2].visibility(), RadrootsRelayFetchEventVisibility::NotAdmitted ); assert_eq!( - receipt.events[3].admission, + receipt.events[3].admission(), RadrootsRelayFetchEventAdmission::Invalid ); assert_eq!( - receipt.events[3].admission_code.as_deref(), + receipt.events[3].admission_code(), Some("reply_event_id_invalid") ); assert_eq!( - receipt.events[3].valid_stream, + receipt.events[3].valid_stream(), RadrootsRelayFetchEventValidStream::Ineligible ); assert_eq!( - receipt.events[3].visibility, + receipt.events[3].visibility(), RadrootsRelayFetchEventVisibility::NotAdmitted ); assert_eq!( - receipt.events[4].verification, + receipt.events[4].verification(), RadrootsRelayFetchEventVerification::Failed ); assert_eq!( - receipt.events[4].admission, + receipt.events[4].admission(), RadrootsRelayFetchEventAdmission::NotEvaluated ); assert_eq!( - receipt.events[5].verification, + receipt.events[5].verification(), RadrootsRelayFetchEventVerification::NotEvaluated ); assert_eq!( - receipt.events[5].admission, + receipt.events[5].admission(), RadrootsRelayFetchEventAdmission::NotEvaluated ); @@ -2855,19 +2856,19 @@ async fn fetch_reports_final_replaceable_visibility_when_newer_arrives_first() { assert_eq!(receipt.visible_count, 1); assert_eq!(receipt.not_current_count, 1); assert_eq!( - receipt.events[0].visibility, + receipt.events[0].visibility(), RadrootsRelayFetchEventVisibility::Visible ); assert_eq!( - receipt.events[1].admission, + receipt.events[1].admission(), RadrootsRelayFetchEventAdmission::Admitted ); assert_eq!( - receipt.events[1].valid_stream, + receipt.events[1].valid_stream(), RadrootsRelayFetchEventValidStream::Eligible ); assert_eq!( - receipt.events[1].visibility, + receipt.events[1].visibility(), RadrootsRelayFetchEventVisibility::NotCurrent ); } @@ -2904,11 +2905,11 @@ async fn fetch_reports_final_replaceable_visibility_when_older_arrives_first() { assert_eq!(receipt.visible_count, 1); assert_eq!(receipt.not_current_count, 1); assert_eq!( - receipt.events[0].visibility, + receipt.events[0].visibility(), RadrootsRelayFetchEventVisibility::NotCurrent ); assert_eq!( - receipt.events[1].visibility, + receipt.events[1].visibility(), RadrootsRelayFetchEventVisibility::Visible ); } @@ -2958,7 +2959,7 @@ async fn fetch_maps_one_final_visibility_snapshot_back_to_duplicate_receipts() { receipt .events .iter() - .map(|event| event.event_id.as_deref()) + .map(|event| event.event_id()) .collect::<Vec<_>>(), vec![ Some(older_id.as_str()), @@ -2970,7 +2971,7 @@ async fn fetch_maps_one_final_visibility_snapshot_back_to_duplicate_receipts() { receipt .events .iter() - .map(|event| event.visibility) + .map(|event| event.visibility()) .collect::<Vec<_>>(), vec![ RadrootsRelayFetchEventVisibility::NotCurrent, @@ -2978,7 +2979,7 @@ async fn fetch_maps_one_final_visibility_snapshot_back_to_duplicate_receipts() { RadrootsRelayFetchEventVisibility::NotCurrent, ] ); - assert!(receipt.events[2].duplicate); + assert!(receipt.events[2].was_duplicate()); } #[tokio::test] @@ -3024,23 +3025,23 @@ async fn fetch_reports_store_suppression_when_deletion_precedes_target_replay() assert_eq!(receipt.suppressed_count, 1); assert_eq!(receipt.not_current_count, 0); assert_eq!( - receipt.events[0].visibility, + receipt.events[0].visibility(), RadrootsRelayFetchEventVisibility::Visible ); assert_eq!( - receipt.events[1].event_id.as_deref(), + receipt.events[1].event_id(), Some(target.id.to_hex().as_str()) ); assert_eq!( - receipt.events[1].admission, + receipt.events[1].admission(), RadrootsRelayFetchEventAdmission::Admitted ); assert_eq!( - receipt.events[1].valid_stream, + receipt.events[1].valid_stream(), RadrootsRelayFetchEventValidStream::Eligible ); assert_eq!( - receipt.events[1].visibility, + receipt.events[1].visibility(), RadrootsRelayFetchEventVisibility::Suppressed ); } @@ -3076,14 +3077,14 @@ async fn fetch_reports_ephemeral_events_as_not_persisted_without_duplicate_or_st assert_eq!(receipt.valid_stream_eligible_count, 0); assert_eq!(receipt.events.len(), 2); assert!(receipt.events.iter().all(|event| { - !event.inserted - && !event.duplicate - && event.not_persisted - && event.verification == RadrootsRelayFetchEventVerification::Verified - && event.admission == RadrootsRelayFetchEventAdmission::Admitted - && event.admission_code.is_none() - && event.valid_stream == RadrootsRelayFetchEventValidStream::Ineligible - && event.visibility == RadrootsRelayFetchEventVisibility::NotPersisted + !event.was_inserted() + && !event.was_duplicate() + && event.was_not_persisted() + && event.verification() == RadrootsRelayFetchEventVerification::Verified + && event.admission() == RadrootsRelayFetchEventAdmission::Admitted + && event.admission_code().is_none() + && event.valid_stream() == RadrootsRelayFetchEventValidStream::Ineligible + && event.visibility() == RadrootsRelayFetchEventVisibility::NotPersisted })); assert!( store @@ -3146,9 +3147,9 @@ async fn fetch_rejects_out_of_filter_events_before_store_mutation() { assert_eq!(receipt.malformed_count, 0); assert_eq!(receipt.admission_unsupported_count, 0); assert_eq!(receipt.events.len(), 3); - assert!(receipt.events[0].out_of_filter); - assert!(!receipt.events[1].out_of_filter); - assert!(receipt.events[2].out_of_filter); + assert!(receipt.events[0].is_out_of_filter()); + assert!(!receipt.events[1].is_out_of_filter()); + assert!(receipt.events[2].is_out_of_filter()); assert!( store .raw_event(accepted.id_str()) @@ -3212,10 +3213,10 @@ async fn fetch_event_cap_counts_accepted_in_filter_events_and_preserves_later_co assert_eq!(receipt.out_of_filter_count, 1); assert_eq!(receipt.skipped_over_limit_count, 1); assert_eq!(receipt.events.len(), 4); - assert!(receipt.events[0].malformed); - assert!(receipt.events[1].out_of_filter); - assert!(receipt.events[2].inserted); - assert!(receipt.events[3].skipped_over_limit); + assert!(receipt.events[0].is_malformed()); + assert!(receipt.events[1].is_out_of_filter()); + assert!(receipt.events[2].was_inserted()); + assert!(receipt.events[3].was_skipped_over_limit()); assert_eq!(receipt.eose_count, 1); assert_eq!(receipt.closed_count, 1); assert_eq!(receipt.notice_count, 1); @@ -3318,10 +3319,10 @@ async fn fetch_relay_events_applies_shared_filter_limit_and_outcome_evidence() { assert_eq!(receipt.closed_count, 1); assert_eq!(receipt.notice_count, 1); assert_eq!(receipt.event_receipts.len(), 4); - assert!(receipt.event_receipts[0].malformed); - assert!(receipt.event_receipts[1].out_of_filter); - assert!(!receipt.event_receipts[2].malformed); - assert!(receipt.event_receipts[3].skipped_over_limit); + assert!(receipt.event_receipts[0].is_malformed()); + assert!(receipt.event_receipts[1].is_out_of_filter()); + assert!(!receipt.event_receipts[2].is_malformed()); + assert!(receipt.event_receipts[3].was_skipped_over_limit()); } #[tokio::test] @@ -3468,7 +3469,7 @@ async fn fetch_raw_json_budget_charges_every_preparse_event_class() { assert_eq!(receipt.duplicate_count, 1); assert_eq!(receipt.skipped_over_limit_count, 2); assert_eq!(receipt.events.len(), 5); - assert!(receipt.events[4].skipped_over_limit); + assert!(receipt.events[4].was_skipped_over_limit()); assert_eq!(receipt.eose_count, 2); } @@ -3721,6 +3722,118 @@ fn fetch_relay_outcomes_and_failures_seal_state_and_strict_wire() { assert!(serde_json::from_value::<RadrootsRelayFetchFailure>(unknown_failure).is_err()); } +#[test] +fn fetch_event_receipts_reject_oversized_and_incoherent_wire_state() { + let event_id = "a".repeat(64); + let exact_message = "m".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES); + let accepted_wire = serde_json::json!({ + "relay_url": RELAY_PRIMARY_WSS, + "event_id": event_id, + "inserted": false, + "duplicate": false, + "not_persisted": false, + "malformed": false, + "out_of_filter": false, + "skipped_over_limit": false, + "verification": "verified", + "admission": "not_evaluated", + "admission_code": null, + "valid_stream": "not_evaluated", + "visibility": "not_evaluated", + "message": exact_message, + }); + let accepted = + serde_json::from_value::<radroots_transport_nostr::RadrootsRelayFetchEventReceipt>( + accepted_wire.clone(), + ) + .expect("exact-limit accepted event receipt"); + assert_eq!(accepted.relay_url(), RELAY_PRIMARY_WSS); + assert_eq!(accepted.event_id(), Some(event_id.as_str())); + assert_eq!( + serde_json::from_value::<radroots_transport_nostr::RadrootsRelayFetchEventReceipt>( + serde_json::to_value(&accepted).expect("event receipt wire"), + ) + .expect("strict event receipt reload"), + accepted + ); + + let mut oversized_message = accepted_wire.clone(); + oversized_message["message"] = + serde_json::json!("m".repeat(RADROOTS_TRANSPORT_DIAGNOSTIC_MAX_BYTES + 1)); + assert!( + serde_json::from_value::<radroots_transport_nostr::RadrootsRelayFetchEventReceipt>( + oversized_message, + ) + .is_err() + ); + + let mut exact_code = accepted_wire.clone(); + exact_code["inserted"] = serde_json::json!(true); + exact_code["admission"] = serde_json::json!("admitted"); + exact_code["admission_code"] = + serde_json::json!("c".repeat(radroots_transport::RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES)); + exact_code["valid_stream"] = serde_json::json!("eligible"); + exact_code["visibility"] = serde_json::json!("visible"); + assert!( + serde_json::from_value::<radroots_transport_nostr::RadrootsRelayFetchEventReceipt>( + exact_code.clone(), + ) + .is_ok() + ); + exact_code["admission_code"] = serde_json::json!( + "c".repeat(radroots_transport::RADROOTS_TRANSPORT_IDENTIFIER_MAX_BYTES + 1) + ); + assert!( + serde_json::from_value::<radroots_transport_nostr::RadrootsRelayFetchEventReceipt>( + exact_code, + ) + .is_err() + ); + + for mutation in [ + ("duplicate", serde_json::json!(true)), + ("event_id", serde_json::json!("not-an-event-id")), + ("extra", serde_json::json!(true)), + ] { + let mut wire = accepted_wire.clone(); + wire["inserted"] = serde_json::json!(true); + wire[mutation.0] = mutation.1; + assert!( + serde_json::from_value::<radroots_transport_nostr::RadrootsRelayFetchEventReceipt>( + wire + ) + .is_err(), + "mutation {} must be rejected", + mutation.0 + ); + } + + let mut malformed_verified = accepted_wire.clone(); + malformed_verified["malformed"] = serde_json::json!(true); + assert!( + serde_json::from_value::<radroots_transport_nostr::RadrootsRelayFetchEventReceipt>( + malformed_verified, + ) + .is_err() + ); + let mut unevaluated_with_semantics = accepted_wire.clone(); + unevaluated_with_semantics["admission_code"] = serde_json::json!("code"); + assert!( + serde_json::from_value::<radroots_transport_nostr::RadrootsRelayFetchEventReceipt>( + unevaluated_with_semantics, + ) + .is_err() + ); + let mut false_not_persisted_visibility = accepted_wire; + false_not_persisted_visibility["visibility"] = serde_json::json!("not_persisted"); + assert!( + serde_json::from_value::<radroots_transport_nostr::RadrootsRelayFetchEventReceipt>( + false_not_persisted_visibility, + ) + .is_err() + ); +} + #[tokio::test] async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() { let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); @@ -5556,9 +5669,9 @@ async fn smoke_relay_fetch_processes_one_thousand_event_receipts() { assert_eq!(receipt.valid_stream_eligible_count, 1_000); assert_eq!(receipt.visible_count, 1_000); assert_eq!(receipt.events.len(), 1_000); - assert!(receipt.events.iter().all(|event| event.valid_stream + assert!(receipt.events.iter().all(|event| event.valid_stream() == RadrootsRelayFetchEventValidStream::Eligible - && event.visibility == RadrootsRelayFetchEventVisibility::Visible)); + && event.visibility() == RadrootsRelayFetchEventVisibility::Visible)); let replay = store.valid_stream_after(0, 1_000).await.expect("replay"); assert_eq!(replay.len(), 1_000); }