Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions crates/buzz-core/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,40 @@ 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<usize>, 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::*;
use crate::test_helpers::{make_event_with_keys, make_stored_event};
use chrono::Utc;
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};

#[test]
fn clamp_limit_table() {
let cases: &[(Option<usize>, 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")
Expand Down
29 changes: 14 additions & 15 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ fn extract_channel_from_filter(filter: &nostr::Filter) -> Option<uuid::Uuid> {
// 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
Expand Down Expand Up @@ -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<Vec<u32>> = filter
.kinds
.as_ref()
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
26 changes: 18 additions & 8 deletions crates/buzz-relay/src/handlers/req.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1244,6 +1241,9 @@ fn extract_channel_id_from_filter(filter: &Filter) -> Option<uuid::Uuid> {
values.iter().next()?.parse::<uuid::Uuid>().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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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());
Expand Down
Loading