lib

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

commit 75e1483797adb413d2f815cebeeb790f2a77fe84
parent 33456558a86995abb9775bed8ef639e8b6659c69
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 07:57:40 +0000

transport: bound relay fetch controls

- derive relay fetch limits from shared transport authority
- cap relay targets filters compact JSON and total deadlines
- reject oversized controls before unbounded collection growth
- prove exact limits and feature profiles without relay I/O

Diffstat:
Mcrates/transport_nostr/Cargo.toml | 12+++++++++++-
Mcrates/transport_nostr/src/fetch.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/transport_nostr/src/lib.rs | 6++++--
Mcrates/transport_nostr/src/relay.rs | 29++++++++++++++++++++++++++++-
Mcrates/transport_nostr/tests/transport.rs | 151+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
5 files changed, 253 insertions(+), 14 deletions(-)

diff --git a/crates/transport_nostr/Cargo.toml b/crates/transport_nostr/Cargo.toml @@ -52,7 +52,7 @@ radroots_outbox = { workspace = true, optional = true, default-features = false, "sqlite", "runtime-tokio", ] } -radroots_transport = { workspace = true, default-features = false } +radroots_transport = { workspace = true, default-features = false, features = ["serde"] } futures = { workspace = true } hex = { workspace = true } nostr = { workspace = true } @@ -69,5 +69,15 @@ radroots_event_codec = { workspace = true, features = ["serde_json"] } sha2 = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } +[[test]] +name = "phase1_outbox_publication" +path = "tests/phase1_outbox_publication.rs" +required-features = ["runtime-tokio"] + +[[test]] +name = "transport" +path = "tests/transport.rs" +required-features = ["runtime-tokio"] + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/transport_nostr/src/fetch.rs b/crates/transport_nostr/src/fetch.rs @@ -7,12 +7,17 @@ use futures::{StreamExt, future::BoxFuture}; use nostr::{JsonUtil, filter::MatchEventOptions}; use radroots_event::wire::v1::DEFAULT_RAW_JSON_MAX_BYTES; use radroots_event_store::{ - RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventAdmissionStatus, RadrootsEventIngest, - RadrootsEventPersistence, RadrootsEventStore, RadrootsEventVisibility, - RadrootsTransportObservation, RadrootsTransportObservationType, + RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventPersistence, + RadrootsEventStore, RadrootsEventVisibility, RadrootsTransportObservation, + RadrootsTransportObservationType, }; use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter}; -use radroots_transport::{RadrootsTransportKind, RadrootsTransportTarget}; +use radroots_transport::{ + RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT, 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_TOTAL_DEADLINE_MAX_MS, RadrootsTransportKind, RadrootsTransportTarget, +}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex, PoisonError}; @@ -20,10 +25,17 @@ use std::sync::{Arc, Mutex, PoisonError}; const DEFAULT_RELAY_FETCH_TIMEOUT_MS: u64 = 10_000; const DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER: usize = 64; pub const RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX: usize = - RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX as usize; + RADROOTS_TRANSPORT_FETCH_ADMITTED_EVENT_MAX_COUNT; pub const RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX: usize = - RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX * DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER; -pub const RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX: usize = 64 * 1024 * 1024; + RADROOTS_TRANSPORT_FETCH_RAW_ITEM_MAX_COUNT; +pub const RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX: usize = + RADROOTS_TRANSPORT_FETCH_RAW_JSON_MAX_BYTES; +pub const RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX: usize = RADROOTS_TRANSPORT_FETCH_FILTER_MAX_COUNT; +pub const RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX: usize = + RADROOTS_TRANSPORT_FETCH_FILTER_MAX_BYTES; +pub const RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX: usize = + RADROOTS_TRANSPORT_FETCH_FILTERS_MAX_BYTES; +pub const RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX: u64 = RADROOTS_TRANSPORT_TOTAL_DEADLINE_MAX_MS; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RadrootsRelayFetchMode { @@ -41,7 +53,41 @@ impl RadrootsRelayFetchFilters { where I: IntoIterator<Item = RadrootsNostrFilter>, { - let filters = filters.into_iter().collect::<Vec<_>>(); + let mut bounded_filters = Vec::new(); + let mut aggregate_json_bytes = 0usize; + for filter in filters { + if bounded_filters.len() == RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX { + return Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "filter_count", + max: RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX, + actual: RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX + 1, + }); + } + let filter_json_bytes = filter.as_json().len(); + if filter_json_bytes > RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX { + return Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "filter_json_bytes", + max: RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX, + actual: filter_json_bytes, + }); + } + aggregate_json_bytes = aggregate_json_bytes.checked_add(filter_json_bytes).ok_or( + RadrootsRelayTransportError::FetchLimitTooLarge { + field: "filter_set_json_bytes", + max: RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX, + actual: usize::MAX, + }, + )?; + if aggregate_json_bytes > RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX { + return Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "filter_set_json_bytes", + max: RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX, + actual: aggregate_json_bytes, + }); + } + bounded_filters.push(filter); + } + let filters = bounded_filters; if filters.is_empty() { return Err(RadrootsRelayTransportError::EmptyFetchFilters); } @@ -244,6 +290,13 @@ fn ensure_positive_timeout( if value == 0 { return Err(RadrootsRelayTransportError::InvalidFetchLimit { field }); } + if value > RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX { + return Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field, + max: RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX as usize, + actual: usize::try_from(value).unwrap_or(usize::MAX), + }); + } Ok(()) } diff --git a/crates/transport_nostr/src/lib.rs b/crates/transport_nostr/src/lib.rs @@ -15,8 +15,10 @@ pub use error::RadrootsRelayTransportError; pub use fetch::fetch_relay_events_blocking; #[cfg(feature = "storage")] pub use fetch::{ - RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, - RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, RadrootsMockRelayFetchAdapter, + RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX, + RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX, RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX, + RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, + RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX, RadrootsMockRelayFetchAdapter, RadrootsNostrClientFetchAdapter, RadrootsRelayFetchAdapter, RadrootsRelayFetchEventAdmission, RadrootsRelayFetchEventReceipt, RadrootsRelayFetchEventValidStream, RadrootsRelayFetchEventVerification, RadrootsRelayFetchEventVisibility, diff --git a/crates/transport_nostr/src/relay.rs b/crates/transport_nostr/src/relay.rs @@ -1,7 +1,10 @@ #![forbid(unsafe_code)] use crate::RadrootsRelayTransportError; -use radroots_transport::RadrootsTransportTarget; +use radroots_transport::{ + RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + RadrootsTransportTarget, +}; use std::fmt; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use url::Url; @@ -34,6 +37,16 @@ impl RadrootsRelayUrl { policy: RadrootsRelayUrlPolicy, ) -> Result<Self, RadrootsRelayTransportError> { let original = value.as_ref(); + if original.len() > RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES { + return Err(RadrootsRelayTransportError::RelayUrlParse { + url: "<oversized>".to_owned(), + reason: format!( + "relay URL exceeds {} UTF-8 bytes: observed {}", + RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, + original.len() + ), + }); + } let parsed = Url::parse(original).map_err(|error| RadrootsRelayTransportError::RelayUrlParse { url: original.to_owned(), @@ -265,6 +278,13 @@ impl RadrootsRelayTargetSet { { let mut ordered_relays = Vec::new(); for relay in relays { + if ordered_relays.len() == RADROOTS_TRANSPORT_TARGET_MAX_COUNT { + return Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "relay_target_count", + max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + actual: RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1, + }); + } let relay = RadrootsRelayUrl::parse(relay, policy)?; if ordered_relays.iter().any(|existing| existing == &relay) { return Err(RadrootsRelayTransportError::DuplicateRelayUrl { @@ -283,6 +303,13 @@ impl RadrootsRelayTargetSet { pub fn from_urls(relays: Vec<RadrootsRelayUrl>) -> Result<Self, RadrootsRelayTransportError> { let mut ordered_relays = Vec::new(); for relay in relays { + if ordered_relays.len() == RADROOTS_TRANSPORT_TARGET_MAX_COUNT { + return Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "relay_target_count", + max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + actual: RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1, + }); + } if ordered_relays.iter().any(|existing| existing == &relay) { return Err(RadrootsRelayTransportError::DuplicateRelayUrl { url: relay.into_string(), diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -19,6 +19,7 @@ use radroots_outbox::{ RadrootsOutboxOperationStatus, }; use radroots_transport::{ + RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES, RADROOTS_TRANSPORT_TARGET_MAX_COUNT, RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, @@ -28,8 +29,10 @@ use radroots_transport::{ RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, }; use radroots_transport_nostr::{ - RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, - RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, RadrootsMockRelayFetchAdapter, + RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX, + RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX, RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX, + RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, + RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX, RadrootsMockRelayFetchAdapter, RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, RadrootsOutboxPublishPolicy, RadrootsRelayFetchEventAdmission, RadrootsRelayFetchEventValidStream, RadrootsRelayFetchEventVerification, RadrootsRelayFetchEventVisibility, @@ -606,6 +609,21 @@ fn unsupported_relay_fetch_filter(limit: usize) -> RadrootsNostrFilter { .limit(limit) } +fn relay_fetch_filter_with_json_bytes(target_bytes: usize) -> RadrootsNostrFilter { + let base = radroots_nostr_filter_tag(RadrootsNostrFilter::new(), "t", vec!["x".to_owned()]) + .expect("base relay fetch filter"); + let base_bytes = base.as_json().len(); + assert!(target_bytes >= base_bytes); + let filter = radroots_nostr_filter_tag( + RadrootsNostrFilter::new(), + "t", + vec!["x".repeat(target_bytes - base_bytes + 1)], + ) + .expect("sized relay fetch filter"); + assert_eq!(filter.as_json().len(), target_bytes); + filter +} + fn fixture_relay_targets() -> RadrootsRelayTargetSet { RadrootsRelayTargetSet::new( [RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS, RELAY_TERTIARY_WSS], @@ -888,6 +906,69 @@ fn relay_url_validation_and_target_normalization() { } #[test] +fn relay_urls_and_target_sets_enforce_resource_bounds() { + let prefix = "wss://relay.example.com/"; + let exact = format!( + "{prefix}{}", + "x".repeat(RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES - prefix.len()) + ); + let relay = RadrootsRelayUrl::parse(&exact, RadrootsRelayUrlPolicy::Public) + .expect("maximum-length relay URL"); + assert_eq!( + relay.as_str().len(), + RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES + ); + + let one_over = format!("{exact}x"); + assert!(matches!( + RadrootsRelayUrl::parse(one_over, RadrootsRelayUrlPolicy::Public), + Err(RadrootsRelayTransportError::RelayUrlParse { url, reason }) + if url == "<oversized>" + && reason.contains(&RADROOTS_TRANSPORT_ENDPOINT_URI_MAX_BYTES.to_string()) + )); + + let exact_targets = (0..RADROOTS_TRANSPORT_TARGET_MAX_COUNT) + .map(|index| format!("wss://relay-{index}.example.com")) + .collect::<Vec<_>>(); + let targets = RadrootsRelayTargetSet::new( + exact_targets.iter().map(String::as_str), + RadrootsRelayUrlPolicy::Public, + ) + .expect("maximum relay target set"); + assert_eq!(targets.len(), RADROOTS_TRANSPORT_TARGET_MAX_COUNT); + + let one_over_targets = (0..=RADROOTS_TRANSPORT_TARGET_MAX_COUNT) + .map(|index| format!("wss://relay-{index}.example.com")) + .collect::<Vec<_>>(); + assert!(matches!( + RadrootsRelayTargetSet::new( + one_over_targets.iter().map(String::as_str), + RadrootsRelayUrlPolicy::Public, + ), + Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "relay_target_count", + max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + actual, + }) if actual == RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1 + )); + + let parsed_one_over = one_over_targets + .iter() + .map(|url| { + RadrootsRelayUrl::parse(url, RadrootsRelayUrlPolicy::Public).expect("bounded relay URL") + }) + .collect::<Vec<_>>(); + assert!(matches!( + RadrootsRelayTargetSet::from_urls(parsed_one_over), + Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "relay_target_count", + max: RADROOTS_TRANSPORT_TARGET_MAX_COUNT, + actual, + }) if actual == RADROOTS_TRANSPORT_TARGET_MAX_COUNT + 1 + )); +} + +#[test] fn transport_target_and_relay_adapter_share_canonical_url_identity() { for (raw, policy) in [ ( @@ -1820,6 +1901,53 @@ fn fetch_requests_reject_empty_filter_sets() { } #[test] +fn fetch_filters_enforce_count_and_compact_json_bounds() { + let maximum_filter = + relay_fetch_filter_with_json_bytes(RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX); + let exact_filters = vec![maximum_filter.clone(); RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX]; + let filters = RadrootsRelayFetchFilters::new(exact_filters).expect("maximum filter set"); + assert_eq!( + filters.as_slice().len(), + RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX + ); + assert_eq!( + filters + .as_slice() + .iter() + .map(|filter| filter.as_json().len()) + .sum::<usize>(), + RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX + ); + assert_eq!( + RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX * RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX, + RADROOTS_RELAY_FETCH_FILTER_SET_JSON_BYTE_LIMIT_MAX + ); + + assert!(matches!( + RadrootsRelayFetchFilters::new(vec![ + post_relay_fetch_filter(1); + RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX + 1 + ]), + Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "filter_count", + max: RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX, + actual, + }) if actual == RADROOTS_RELAY_FETCH_FILTER_LIMIT_MAX + 1 + )); + + let oversized_filter = + relay_fetch_filter_with_json_bytes(RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX + 1); + assert!(matches!( + RadrootsRelayFetchFilters::new([oversized_filter]), + Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "filter_json_bytes", + max: RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX, + actual, + }) if actual == RADROOTS_RELAY_FETCH_FILTER_JSON_BYTE_LIMIT_MAX + 1 + )); +} + +#[test] fn fetch_requests_reject_zero_limits_and_timeouts() { let filter = post_relay_fetch_filter(1); let filters = RadrootsRelayFetchFilters::new([filter.clone()]).expect("filters"); @@ -1853,6 +1981,25 @@ fn fetch_requests_reject_zero_limits_and_timeouts() { request.clone().with_timeout_ms(0), Err(RadrootsRelayTransportError::InvalidFetchLimit { field }) if field == "timeout_ms" )); + let maximum_timeout = request + .clone() + .with_timeout_ms(RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX) + .expect("maximum timeout"); + assert_eq!( + maximum_timeout.timeout_ms(), + RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX + ); + assert!(matches!( + request + .clone() + .with_timeout_ms(RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX + 1), + Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "timeout_ms", + max, + actual, + }) if max == RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX as usize + && actual == RADROOTS_RELAY_FETCH_TIMEOUT_MS_MAX as usize + 1 + )); assert!(matches!( request.clone().with_raw_event_scan_limit(0), Err(RadrootsRelayTransportError::InvalidFetchLimit { field }) if field == "max_raw_events"