diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 73b7d6e56b1..6d8b23094cf 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -189,6 +189,15 @@ fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool { true } +/// Resolves a requested result limit: `default` when absent, otherwise the +/// request saturated to `max`. `Some(0)` stays `0` (NIP-01 "no results"). +/// +/// Saturates before narrowing, so a huge `usize` limit cannot wrap to `0` +/// through `as u32` or turn negative through `as i64`. +pub fn clamp_limit(limit: Option, default: u32, max: u32) -> u32 { + limit.map_or(default, |l| u32::try_from(l).unwrap_or(u32::MAX).min(max)) +} + #[cfg(test)] mod tests { use super::*; @@ -196,6 +205,24 @@ mod tests { use chrono::Utc; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + #[test] + fn clamp_limit_table() { + let cases: &[(Option, u32, u32, u32)] = &[ + (None, 20, 100, 20), + (Some(0), 20, 100, 0), + (Some(7), 20, 100, 7), + (Some(100), 20, 100, 100), + (Some(101), 20, 100, 100), + (Some(u32::MAX as usize), 20, 100, 100), + (Some(u32::MAX as usize + 1), 20, 100, 100), + (Some(usize::MAX), 20, 100, 100), + (Some(usize::MAX), 1000, u32::MAX, u32::MAX), + ]; + for &(limit, default, max, want) in cases { + assert_eq!(clamp_limit(limit, default, max), want, "{limit:?}"); + } + } + fn stored_with_tag(tag: Tag) -> StoredEvent { let keys = Keys::generate(); let event = EventBuilder::new(Kind::TextNote, "test") diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 0b6c481a887..f47075a6653 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -711,7 +711,7 @@ fn extract_channel_from_filter(filter: &nostr::Filter) -> Option { // Nostr filter JSON. nostr::Filter silently drops unknown fields during // deserialization, so we extract them from the raw JSON Value first. -const BRIDGE_FEED_MAX_LIMIT: i64 = 100; +const BRIDGE_FEED_MAX_LIMIT: u32 = 100; const BRIDGE_THREAD_MAX_LIMIT: u32 = 500; /// The `before_id` extension field, with "present but malformed" kept distinct @@ -1064,11 +1064,12 @@ async fn handle_channel_window_filter( } }; - let limit = filter - .limit - .map(|l| (l as u32).min(BRIDGE_WINDOW_MAX_LIMIT)) - .unwrap_or(BRIDGE_WINDOW_DEFAULT_LIMIT) - .max(1); + let limit = buzz_core::filter::clamp_limit( + filter.limit, + BRIDGE_WINDOW_DEFAULT_LIMIT, + BRIDGE_WINDOW_MAX_LIMIT, + ) + .max(1); let kind_filter: Option> = filter .kinds .as_ref() @@ -1911,10 +1912,11 @@ async fn query_member_events( None => continue, }; - let limit = filter - .limit - .map(|l| (l as i64).min(BRIDGE_FEED_MAX_LIMIT)) - .unwrap_or(20); + let limit = i64::from(buzz_core::filter::clamp_limit( + filter.limit, + 20, + BRIDGE_FEED_MAX_LIMIT, + )); let since = filter .since .and_then(|s| chrono::DateTime::from_timestamp(s.as_secs() as i64, 0)); @@ -2029,10 +2031,7 @@ async fn query_member_events( } } - let limit = filter - .limit - .unwrap_or(100) - .min(BRIDGE_THREAD_MAX_LIMIT as usize) as u32; + let limit = buzz_core::filter::clamp_limit(filter.limit, 100, BRIDGE_THREAD_MAX_LIMIT); let thread_cursor = extract_thread_cursor(raw); let thread_replies = state .db @@ -2728,7 +2727,7 @@ async fn handle_bridge_search( _ => continue, }; - let limit = filter.limit.unwrap_or(100).min(500) as u32; + let limit = buzz_core::filter::clamp_limit(filter.limit, 100, 500); if limit == 0 { continue; } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index b5e9347941b..c58340e15f3 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -867,10 +867,7 @@ async fn handle_search_req( let accessible_channels = &*super::system_channels::filter_channel_scope(filter, implicit_channels, named_only); - let limit = filter - .limit - .map(|l| (l as u32).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32)) - .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32); + let limit = buzz_core::filter::clamp_limit(filter.limit, PAGE_LIMIT_MAX, PAGE_LIMIT_MAX); if limit == 0 { continue; // NIP-01: limit 0 means "no results from this filter" @@ -1244,6 +1241,9 @@ fn extract_channel_id_from_filter(filter: &Filter) -> Option { values.iter().next()?.parse::().ok() } +/// [`buzz_db::DEFAULT_MAX_PAGE_LIMIT`] as the `u32` that [`buzz_core::filter::clamp_limit`] takes. +const PAGE_LIMIT_MAX: u32 = buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32; + /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. /// /// Each filter is queried independently so that per-filter `limit` and time @@ -1271,10 +1271,11 @@ fn filter_to_query_params( let until = filter .until .and_then(|u| chrono::DateTime::from_timestamp(u.as_secs() as i64, 0)); - let limit = filter - .limit - .map(|l| (l as i64).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT)) - .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT); + let limit = i64::from(buzz_core::filter::clamp_limit( + filter.limit, + PAGE_LIMIT_MAX, + PAGE_LIMIT_MAX, + )); // Push author filter into SQL. Single-author uses the indexed `pubkey` column; // multi-author uses the `authors` IN-list pushdown added in the pure-nostr PR. @@ -2999,6 +3000,15 @@ mod tests { .expect("max_limit") as i64 } + #[test] + fn huge_filter_limit_is_clamped_to_page_max() { + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let q = filter_to_query_params(&Filter::new().limit(usize::MAX), None, community); + assert_eq!(q.limit, Some(buzz_db::DEFAULT_MAX_PAGE_LIMIT)); + let q = filter_to_query_params(&Filter::new().limit(0), None, community); + assert_eq!(q.limit, Some(0)); + } + #[test] fn artifact_d_filter_is_pushed_before_limit() { let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4());