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_THRESHOLD: usize = 5;
const TILDE_WINDOW: Duration = Duration::from_secs(5);
const TILDE_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_times: Vec<Instant>,
tilde_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_times: Vec::new(),
tilde_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_flood(&mut self, now: Instant) -> FloodResult {
if let Some(until) = self.tilde_blocked_until {
if now < until {
self.tilde_blocked_until = Some(now + TILDE_BLOCK);
return FloodResult::Blocked;
}
self.tilde_blocked_until = None;
}
self.tilde_times.push(now);
let count = prune_window(&mut self.tilde_times, now, TILDE_WINDOW);
if count >= TILDE_THRESHOLD {
self.tilde_blocked_until = Some(now + TILDE_BLOCK);
self.tilde_times.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(buffer_id)
&& now < until
{
self.nick_blocked_until
.insert(buffer_id.to_string(), now + NICK_BLOCK);
return true;
}
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;
}
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()
}
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 new_state_has_no_blocks() {
let state = FloodState::new();
assert!(state.ctcp_blocked_until.is_none());
assert!(state.tilde_blocked_until.is_none());
assert!(state.msg_window.is_empty());
assert!(state.blocked_texts.is_empty());
assert!(state.nick_times.is_empty());
assert!(state.nick_blocked_until.is_empty());
}
#[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 {
let t = now + Duration::from_millis(i * 100);
assert_eq!(state.check_ctcp_flood(t), 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_under_threshold_passes() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..4 {
assert_eq!(
state.check_tilde_flood(now + Duration::from_millis(i * 100)),
FloodResult::Allow
);
}
}
#[test]
fn tilde_at_threshold_triggers() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
let result = state.check_tilde_flood(now + Duration::from_millis(i * 100));
if i < 4 {
assert_eq!(result, FloodResult::Allow);
} else {
assert_eq!(result, FloodResult::Triggered);
}
}
}
#[test]
fn tilde_block_expires() {
let mut state = FloodState::new();
let now = Instant::now();
for i in 0..5 {
state.check_tilde_flood(now + Duration::from_millis(i * 100));
}
assert_eq!(
state.check_tilde_flood(now + Duration::from_secs(61)),
FloodResult::Allow
);
}
#[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();
let now = Instant::now();
assert_eq!(
state.check_duplicate_flood("", true, 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 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"));
}
}