use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FloodResult {
Allow,
Triggered,
Blocked,
}
impl FloodResult {
pub const fn suppressed(self) -> bool {
matches!(self, Self::Triggered | Self::Blocked)
}
}
const CTCP_THRESHOLD: usize = 5;
const CTCP_WINDOW: Duration = Duration::from_secs(5);
const CTCP_BLOCK: Duration = Duration::from_secs(60);
const TILDE_NICK_THRESHOLD: usize = 5;
const TILDE_NICK_WINDOW: Duration = Duration::from_secs(5);
const TILDE_NICK_BLOCK: Duration = Duration::from_secs(60);
const PM_STORM_THRESHOLD: usize = 6;
const PM_STORM_WINDOW: Duration = Duration::from_secs(5);
const PM_STORM_BLOCK: Duration = Duration::from_secs(60);
const DUP_MIN_IN_WINDOW: usize = 5;
const DUP_THRESHOLD: usize = 3;
const DUP_WINDOW: Duration = Duration::from_secs(5);
const DUP_BLOCK: Duration = Duration::from_secs(60);
const NICK_THRESHOLD: usize = 5;
const NICK_WINDOW: Duration = Duration::from_secs(3);
const NICK_BLOCK: Duration = Duration::from_secs(60);
pub struct FloodState {
ctcp_times: Vec<Instant>,
ctcp_blocked_until: Option<Instant>,
tilde_nick_times: HashMap<u64, Vec<Instant>>, tilde_nick_blocked: HashMap<u64, Instant>,
pm_storm_nicks: Vec<(u64, Instant)>, pm_storm_blocked_until: Option<Instant>,
msg_window: Vec<(u64, Instant)>,
blocked_texts: HashMap<u64, Instant>,
nick_times: HashMap<String, Vec<Instant>>,
nick_blocked_until: HashMap<String, Instant>,
}
impl FloodState {
pub fn new() -> Self {
Self {
ctcp_times: Vec::new(),
ctcp_blocked_until: None,
tilde_nick_times: HashMap::new(),
tilde_nick_blocked: HashMap::new(),
pm_storm_nicks: Vec::new(),
pm_storm_blocked_until: None,
msg_window: Vec::new(),
blocked_texts: HashMap::new(),
nick_times: HashMap::new(),
nick_blocked_until: HashMap::new(),
}
}
pub fn check_ctcp_flood(&mut self, now: Instant) -> FloodResult {
if let Some(until) = self.ctcp_blocked_until {
if now < until {
self.ctcp_blocked_until = Some(now + CTCP_BLOCK);
return FloodResult::Blocked;
}
self.ctcp_blocked_until = None;
}
self.ctcp_times.push(now);
let count = prune_window(&mut self.ctcp_times, now, CTCP_WINDOW);
if count >= CTCP_THRESHOLD {
self.ctcp_blocked_until = Some(now + CTCP_BLOCK);
self.ctcp_times.clear();
return FloodResult::Triggered;
}
FloodResult::Allow
}
pub fn check_tilde_nick_flood(&mut self, nick: &str, now: Instant) -> FloodResult {
let nick_hash = hash_text(nick);
if let Some(&until) = self.tilde_nick_blocked.get(&nick_hash) {
if now < until {
self.tilde_nick_blocked
.insert(nick_hash, now + TILDE_NICK_BLOCK);
return FloodResult::Blocked;
}
self.tilde_nick_blocked.remove(&nick_hash);
}
let times = self.tilde_nick_times.entry(nick_hash).or_default();
times.push(now);
let count = prune_window(times, now, TILDE_NICK_WINDOW);
if count >= TILDE_NICK_THRESHOLD {
self.tilde_nick_blocked
.insert(nick_hash, now + TILDE_NICK_BLOCK);
times.clear();
return FloodResult::Triggered;
}
if self.tilde_nick_times.len() > 200 {
let cutoff = now.checked_sub(TILDE_NICK_WINDOW).unwrap_or(now);
self.tilde_nick_times.retain(|_, v| {
v.retain(|t| *t >= cutoff);
!v.is_empty()
});
self.tilde_nick_blocked.retain(|_, until| *until > now);
}
FloodResult::Allow
}
pub fn check_pm_tilde_storm(&mut self, nick: &str, now: Instant) -> FloodResult {
if let Some(until) = self.pm_storm_blocked_until {
if now < until {
self.pm_storm_blocked_until = Some(now + PM_STORM_BLOCK);
return FloodResult::Blocked;
}
self.pm_storm_blocked_until = None;
}
let nick_hash = hash_text(nick);
self.pm_storm_nicks.push((nick_hash, now));
let cutoff = now.checked_sub(PM_STORM_WINDOW).unwrap_or(now);
self.pm_storm_nicks.retain(|(_, t)| *t >= cutoff);
let unique = unique_count(&self.pm_storm_nicks);
if unique >= PM_STORM_THRESHOLD {
self.pm_storm_blocked_until = Some(now + PM_STORM_BLOCK);
self.pm_storm_nicks.clear();
return FloodResult::Triggered;
}
FloodResult::Allow
}
pub fn check_duplicate_flood(
&mut self,
text: &str,
is_channel: bool,
now: Instant,
) -> FloodResult {
if !is_channel || text.is_empty() {
return FloodResult::Allow;
}
let hash = hash_text(text);
if let Some(&until) = self.blocked_texts.get(&hash)
&& now < until
{
self.blocked_texts.insert(hash, now + DUP_BLOCK);
return FloodResult::Blocked;
}
self.msg_window.push((hash, now));
let cutoff = now.checked_sub(DUP_WINDOW).unwrap_or(now);
self.msg_window.retain(|(_, t)| *t >= cutoff);
if self.msg_window.len() >= DUP_MIN_IN_WINDOW {
let dupes = self.msg_window.iter().filter(|(h, _)| *h == hash).count();
if dupes >= DUP_THRESHOLD {
self.blocked_texts.insert(hash, now + DUP_BLOCK);
return FloodResult::Triggered;
}
}
if self.blocked_texts.len() > 50 {
self.blocked_texts.retain(|_, until| *until > now);
}
FloodResult::Allow
}
pub fn should_suppress_nick_flood(&mut self, buffer_id: &str, now: Instant) -> bool {
if let Some(until) = self.nick_blocked_until.get_mut(buffer_id) {
if now < *until {
*until = now + NICK_BLOCK;
return true;
}
self.nick_blocked_until.remove(buffer_id);
}
let times = self.nick_times.entry(buffer_id.to_string()).or_default();
times.push(now);
prune_window(times, now, NICK_WINDOW);
if times.len() >= NICK_THRESHOLD {
self.nick_blocked_until
.insert(buffer_id.to_string(), now + NICK_BLOCK);
times.clear();
return true;
}
if self.nick_times.len() > 200 {
let cutoff = now.checked_sub(NICK_WINDOW).unwrap_or(now);
self.nick_times.retain(|_, v| {
v.retain(|t| *t >= cutoff);
!v.is_empty()
});
self.nick_blocked_until.retain(|_, until| *until > now);
}
false
}
pub fn remove_buffer(&mut self, buffer_id: &str) {
self.nick_times.remove(buffer_id);
self.nick_blocked_until.remove(buffer_id);
}
}
impl Default for FloodState {
fn default() -> Self {
Self::new()
}
}
fn hash_text(text: &str) -> u64 {
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
fn unique_count(entries: &[(u64, Instant)]) -> usize {
let mut seen: Vec<u64> = Vec::with_capacity(entries.len());
for &(hash, _) in entries {
if !seen.contains(&hash) {
seen.push(hash);
}
}
seen.len()
}
pub fn prune_window(times: &mut Vec<Instant>, now: Instant, window: Duration) -> usize {
let cutoff = now.checked_sub(window).unwrap_or(now);
let mut i = 0;
while i < times.len() && times[i] < cutoff {
i += 1;
}
if i > 0 {
times.drain(..i);
}
times.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ctcp_under_threshold_passes() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..4 {
let t = now + Duration::from_millis(i * 100);
assert_eq!(
state.check_ctcp_flood(t),
FloodResult::Allow,
"request {i} should pass"
);
}
}
#[test]
fn ctcp_at_threshold_triggers() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..4 {
assert_eq!(
state.check_ctcp_flood(now + Duration::from_millis(i * 100)),
FloodResult::Allow
);
}
assert_eq!(
state.check_ctcp_flood(now + Duration::from_millis(400)),
FloodResult::Triggered
);
}
#[test]
fn ctcp_block_extends_on_continued_flood() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
state.check_ctcp_flood(now + Duration::from_millis(i * 100));
}
assert_eq!(
state.check_ctcp_flood(now + Duration::from_secs(30)),
FloodResult::Blocked
);
}
#[test]
fn ctcp_block_expires() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
state.check_ctcp_flood(now + Duration::from_millis(i * 100));
}
assert_eq!(
state.check_ctcp_flood(now + Duration::from_secs(61)),
FloodResult::Allow
);
}
#[test]
fn ctcp_outside_window_does_not_trigger() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
let t = now + Duration::from_secs(i * 3);
assert_eq!(state.check_ctcp_flood(t), FloodResult::Allow);
}
}
#[test]
fn tilde_nick_under_threshold_passes() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..4 {
assert_eq!(
state.check_tilde_nick_flood("jim", now + Duration::from_millis(i * 100)),
FloodResult::Allow,
"message {i} from jim should pass"
);
}
}
#[test]
fn tilde_nick_at_threshold_blocks_only_that_nick() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
state.check_tilde_nick_flood("jim", now + Duration::from_millis(i * 100));
}
assert_eq!(
state.check_tilde_nick_flood("jim", now + Duration::from_secs(1)),
FloodResult::Blocked
);
assert_eq!(
state.check_tilde_nick_flood("ripsum", now + Duration::from_secs(1)),
FloodResult::Allow
);
}
#[test]
fn tilde_nick_block_expires() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
state.check_tilde_nick_flood("jim", now + Duration::from_millis(i * 100));
}
assert_eq!(
state.check_tilde_nick_flood("jim", now + Duration::from_secs(61)),
FloodResult::Allow,
);
}
#[test]
fn tilde_nick_different_nicks_independent() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..4 {
let t = now + Duration::from_millis(i * 100);
assert_eq!(state.check_tilde_nick_flood("jim", t), FloodResult::Allow);
assert_eq!(state.check_tilde_nick_flood("alice", t), FloodResult::Allow);
}
}
#[test]
fn tilde_nick_outside_window_does_not_trigger() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
let t = now + Duration::from_secs(i * 3);
assert_eq!(state.check_tilde_nick_flood("jim", t), FloodResult::Allow);
}
}
#[test]
fn pm_storm_under_threshold_passes() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
assert_eq!(
state
.check_pm_tilde_storm(&format!("bot{i}"), now + Duration::from_millis(i * 100)),
FloodResult::Allow,
"PM from bot{i} should pass"
);
}
}
#[test]
fn pm_storm_at_threshold_triggers() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
assert_eq!(
state
.check_pm_tilde_storm(&format!("bot{i}"), now + Duration::from_millis(i * 100)),
FloodResult::Allow,
);
}
assert_eq!(
state.check_pm_tilde_storm("bot5", now + Duration::from_millis(500)),
FloodResult::Triggered
);
}
#[test]
fn pm_storm_same_nick_does_not_inflate_count() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..10 {
assert_eq!(
state.check_pm_tilde_storm("spammer", now + Duration::from_millis(i * 50)),
FloodResult::Allow,
"same nick repeat {i} should not trigger storm"
);
}
}
#[test]
fn pm_storm_block_expires() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..6 {
state.check_pm_tilde_storm(&format!("bot{i}"), now + Duration::from_millis(i * 100));
}
assert_eq!(
state.check_pm_tilde_storm("late_bot", now + Duration::from_secs(30)),
FloodResult::Blocked
);
assert_eq!(
state.check_pm_tilde_storm("another", now + Duration::from_secs(61)),
FloodResult::Blocked
);
assert_eq!(
state.check_pm_tilde_storm("legit_user", now + Duration::from_secs(122)),
FloodResult::Allow
);
}
#[test]
fn pm_storm_outside_window_does_not_trigger() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..6 {
assert_eq!(
state.check_pm_tilde_storm(&format!("user{i}"), now + Duration::from_secs(i * 3)),
FloodResult::Allow,
"user{i} at {i}*3s should pass"
);
}
}
#[test]
fn duplicate_non_channel_ignored() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..10 {
assert_eq!(
state.check_duplicate_flood(
"same text",
false,
now + Duration::from_millis(i * 100)
),
FloodResult::Allow
);
}
}
#[test]
fn duplicate_empty_text_ignored() {
let mut state = FloodState::new();
assert_eq!(
state.check_duplicate_flood("", true, Instant::now()),
FloodResult::Allow
);
}
#[test]
fn duplicate_below_window_size_passes() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..4 {
assert_eq!(
state.check_duplicate_flood("spam", true, now + Duration::from_millis(i * 100)),
FloodResult::Allow
);
}
}
#[test]
fn duplicate_at_threshold_triggers() {
let mut state = FloodState::new();
let now = Instant::now();
assert_eq!(
state.check_duplicate_flood("spam", true, now),
FloodResult::Allow
);
assert_eq!(
state.check_duplicate_flood("other1", true, now + Duration::from_millis(100)),
FloodResult::Allow
);
assert_eq!(
state.check_duplicate_flood("spam", true, now + Duration::from_millis(200)),
FloodResult::Allow
);
assert_eq!(
state.check_duplicate_flood("other2", true, now + Duration::from_millis(300)),
FloodResult::Allow
);
assert_eq!(
state.check_duplicate_flood("spam", true, now + Duration::from_millis(400)),
FloodResult::Triggered
);
}
#[test]
fn duplicate_blocked_text_stays_blocked() {
let mut state = FloodState::new();
let now = Instant::now();
assert_eq!(
state.check_duplicate_flood("spam", true, now),
FloodResult::Allow
);
assert_eq!(
state.check_duplicate_flood("a", true, now + Duration::from_millis(100)),
FloodResult::Allow
);
assert_eq!(
state.check_duplicate_flood("spam", true, now + Duration::from_millis(200)),
FloodResult::Allow
);
assert_eq!(
state.check_duplicate_flood("b", true, now + Duration::from_millis(300)),
FloodResult::Allow
);
assert_eq!(
state.check_duplicate_flood("spam", true, now + Duration::from_millis(400)),
FloodResult::Triggered
);
assert_eq!(
state.check_duplicate_flood("spam", true, now + Duration::from_secs(10)),
FloodResult::Blocked
);
}
#[test]
fn duplicate_different_texts_pass() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..10 {
assert_eq!(
state.check_duplicate_flood(
&format!("unique msg {i}"),
true,
now + Duration::from_millis(i * 100)
),
FloodResult::Allow
);
}
}
#[test]
fn nick_under_threshold_passes() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..4 {
assert!(
!state
.should_suppress_nick_flood("conn/chan", now + Duration::from_millis(i * 100))
);
}
}
#[test]
fn nick_at_threshold_triggers() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
let result = state
.should_suppress_nick_flood("conn/#channel", now + Duration::from_millis(i * 100));
if i < 4 {
assert!(!result, "nick change {i} should pass");
} else {
assert!(result, "nick change {i} should trigger");
}
}
}
#[test]
fn nick_different_buffers_independent() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..4 {
assert!(
!state.should_suppress_nick_flood("buf_a", now + Duration::from_millis(i * 100))
);
}
assert!(!state.should_suppress_nick_flood("buf_b", now + Duration::from_millis(500)));
}
#[test]
fn nick_block_extends_on_continued_flood() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
state.should_suppress_nick_flood("buf", now + Duration::from_millis(i * 100));
}
assert!(state.should_suppress_nick_flood("buf", now + Duration::from_secs(30)));
}
#[test]
fn nick_block_expires() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
state.should_suppress_nick_flood("buf", now + Duration::from_millis(i * 100));
}
assert!(!state.should_suppress_nick_flood("buf", now + Duration::from_secs(61)));
}
#[test]
fn nick_outside_window_does_not_trigger() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
let t = now + Duration::from_millis(i * 1500);
assert!(
!state.should_suppress_nick_flood("buf", t),
"nick change at {i}*1.5s should pass"
);
}
}
#[test]
fn prune_window_removes_old_entries() {
let now = Instant::now();
let mut times = vec![
now.checked_sub(Duration::from_secs(10)).unwrap(),
now.checked_sub(Duration::from_secs(8)).unwrap(),
now.checked_sub(Duration::from_secs(3)).unwrap(),
now.checked_sub(Duration::from_secs(1)).unwrap(),
now,
];
let count = prune_window(&mut times, now, Duration::from_secs(5));
assert_eq!(count, 3);
}
#[test]
fn prune_window_empty_vec() {
let mut times: Vec<Instant> = Vec::new();
let count = prune_window(&mut times, Instant::now(), Duration::from_secs(5));
assert_eq!(count, 0);
}
#[test]
fn prune_window_all_recent() {
let now = Instant::now();
let mut times = vec![now, now, now];
let count = prune_window(&mut times, now, Duration::from_secs(5));
assert_eq!(count, 3);
}
#[test]
fn prune_window_all_expired() {
let now = Instant::now();
let mut times = vec![
now.checked_sub(Duration::from_secs(20)).unwrap(),
now.checked_sub(Duration::from_secs(15)).unwrap(),
now.checked_sub(Duration::from_secs(10)).unwrap(),
];
let count = prune_window(&mut times, now, Duration::from_secs(5));
assert_eq!(count, 0);
}
#[test]
fn unique_count_deduplicates() {
let now = Instant::now();
let entries = vec![
(1, now),
(2, now),
(1, now), (3, now),
(2, now), ];
assert_eq!(unique_count(&entries), 3);
}
#[test]
fn default_impl_matches_new() {
let a = FloodState::new();
let b = FloodState::default();
assert!(a.ctcp_times.is_empty());
assert!(b.ctcp_times.is_empty());
assert!(a.ctcp_blocked_until.is_none());
assert!(b.ctcp_blocked_until.is_none());
}
#[test]
fn remove_buffer_cleans_nick_tracking() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..3 {
state.should_suppress_nick_flood("conn/#old", now + Duration::from_millis(i * 100));
}
assert!(state.nick_times.contains_key("conn/#old"));
state.remove_buffer("conn/#old");
assert!(!state.nick_times.contains_key("conn/#old"));
assert!(!state.nick_blocked_until.contains_key("conn/#old"));
}
}