From 9257dff5ef6225be108ad157e4ee7100c81a0813 Mon Sep 17 00:00:00 2001 From: kofany Date: Sun, 8 Mar 2026 12:05:14 +0100 Subject: [PATCH 1/4] Implement penalty-based IRC flood protection Add command-aware flood throttling modeled after irssi and IRCd (RFC 2813 penalty model). Each outgoing command incurs a penalty cost that mirrors the server's own flood detection: PRIVMSG/JOIN/NICK = 2000ms, WHO/WHOIS/ LIST = 4000ms, PONG/QUIT/CAP = 0ms. When accumulated penalty exceeds a configurable threshold (default 10s), outgoing messages are delayed until the penalty drains below the threshold at real-time rate. New config field: flood_penalty_threshold (milliseconds, default 10000, set to 0 to disable). Deprecates the unimplemented burst_window_length and max_messages_in_burst fields. Closes #190 --- src/client/data/config.rs | 23 ++++++++++ src/client/mod.rs | 91 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/client/data/config.rs b/src/client/data/config.rs index 9f65b2bc..46cd1e2a 100644 --- a/src/client/data/config.rs +++ b/src/client/data/config.rs @@ -178,12 +178,24 @@ pub struct Config { /// messages will be delayed automatically as appropriate. In particular, in the past /// `burst_window_length` seconds, there will never be more than `max_messages_in_burst` messages /// sent. + #[deprecated(note = "Unimplemented. Use flood_penalty_threshold instead.")] #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub burst_window_length: Option, /// The maximum number of messages that can be sent in a burst window before they'll be delayed. /// Messages are automatically delayed as appropriate. + #[deprecated(note = "Unimplemented. Use flood_penalty_threshold instead.")] #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub max_messages_in_burst: Option, + /// The penalty threshold in milliseconds for IRC flood protection. Each outgoing command + /// incurs a penalty cost (e.g. PRIVMSG = 2000ms, WHO = 4000ms, PONG = 0ms) that mirrors + /// the IRC server's own flood detection (RFC 2813 penalty model). When accumulated penalty + /// exceeds this threshold, outgoing messages are delayed until the penalty drains below it. + /// Penalty drains in real-time at 1ms per 1ms elapsed. + /// + /// Set to `0` to disable flood protection entirely. + /// Defaults to 10000 (10 seconds), matching the standard IRCd excess flood limit. + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub flood_penalty_threshold: Option, /// Whether the client should use NickServ GHOST to reclaim its primary nickname if it is in /// use. This has no effect if `nick_password` is not set. #[cfg_attr(feature = "serde", serde(skip_serializing_if = "is_false"))] @@ -600,7 +612,9 @@ impl Config { /// system maintains the invariant that in the past `burst_window_length` seconds, the maximum /// number of messages sent is `max_messages_in_burst`. /// This defaults to 8 seconds when not specified. + #[deprecated(note = "Unimplemented. Use flood_penalty_threshold instead.")] pub fn burst_window_length(&self) -> u32 { + #[allow(deprecated)] self.burst_window_length.as_ref().cloned().unwrap_or(8) } @@ -609,10 +623,19 @@ impl Config { /// system maintains the invariant that in the past `burst_window_length` seconds, the maximum /// number of messages sent is `max_messages_in_burst`. /// This defaults to 15 messages when not specified. + #[deprecated(note = "Unimplemented. Use flood_penalty_threshold instead.")] pub fn max_messages_in_burst(&self) -> u32 { + #[allow(deprecated)] self.max_messages_in_burst.as_ref().cloned().unwrap_or(15) } + /// Gets the penalty threshold in milliseconds for IRC flood protection. + /// When accumulated penalty exceeds this value, outgoing messages are delayed. + /// Defaults to 10000ms (10 seconds). Set to 0 to disable. + pub fn flood_penalty_threshold(&self) -> u32 { + self.flood_penalty_threshold.unwrap_or(10_000) + } + /// Gets whether or not to attempt nickname reclamation using NickServ GHOST. /// This defaults to false when not specified. pub fn should_ghost(&self) -> bool { diff --git a/src/client/mod.rs b/src/client/mod.rs index 8c3f08c6..c6a6149c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -825,17 +825,61 @@ impl Sender { pub_sender_base!(); } -/// Future to handle outgoing messages. +/// Future to handle outgoing messages with IRC flood protection. /// -/// Note: this is essentially the same as a version of [SendAll](https://github.com/rust-lang-nursery/futures-rs/blob/master/futures-util/src/sink/send_all.rs) that owns it's sink and stream. +/// Implements a penalty-based throttle modeled after irssi and IRCd (RFC 2813). Each outgoing +/// command incurs a penalty cost in milliseconds. When the accumulated penalty exceeds a +/// configurable threshold (default 10s), messages are delayed until the penalty drains below +/// the threshold. Penalty drains in real-time at 1ms per 1ms elapsed. #[derive(Debug)] pub struct Outgoing { sink: SplitSink, stream: UnboundedReceiver, buffered: Option, + /// Accumulated penalty in milliseconds. + penalty: u64, + /// Threshold above which messages are delayed. 0 = disabled. + penalty_threshold: u64, + /// Last time penalty was drained. + last_penalty_check: tokio::time::Instant, + /// Active delay future for throttling. + delay: Option>>, } impl Outgoing { + /// Returns the penalty cost in milliseconds for a given IRC command. + /// + /// Values mirror the irssi/IRCd penalty model (RFC 2813 §5.8): + /// - Connection control (PONG, QUIT, etc.): 0ms (never throttled) + /// - Standard messages (PRIVMSG, NOTICE, JOIN, etc.): 2000ms + /// - Expensive queries (WHO, WHOIS, LIST, etc.): 4000ms + fn command_penalty(command: &Command) -> u64 { + match command { + // Connection control — never throttled + Command::PONG(..) | Command::QUIT(..) | Command::PASS(..) => 0, + + // CAP negotiation — must not be throttled during registration + Command::CAP(..) | Command::AUTHENTICATE(..) => 0, + + // Expensive server queries + Command::WHO(..) | Command::WHOIS(..) | Command::WHOWAS(..) + | Command::LIST(..) | Command::NAMES(..) | Command::LINKS(..) + | Command::STATS(..) | Command::LUSERS(..) | Command::TRACE(..) + | Command::USERS(..) | Command::MOTD(..) => 4000, + + // Everything else: standard 2s penalty + _ => 2000, + } + } + + /// Drains accumulated penalty based on elapsed real time. + fn drain_penalty(&mut self) { + let now = tokio::time::Instant::now(); + let elapsed = now.duration_since(self.last_penalty_check).as_millis() as u64; + self.penalty = self.penalty.saturating_sub(elapsed); + self.last_penalty_check = now; + } + fn try_start_send( &mut self, cx: &mut Context<'_>, @@ -867,13 +911,48 @@ impl Future for Outgoing { fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = &mut *self; + // If we're waiting on a throttle delay, poll it first. + if let Some(ref mut delay) = this.delay { + ready!(delay.as_mut().poll(cx)); + this.delay = None; + this.drain_penalty(); + } + if let Some(message) = this.buffered.take() { ready!(this.try_start_send(cx, message))? } loop { match this.stream.poll_recv(cx) { - Poll::Ready(Some(message)) => ready!(this.try_start_send(cx, message))?, + Poll::Ready(Some(message)) => { + // Apply penalty-based throttle if enabled. + if this.penalty_threshold > 0 { + let cost = Self::command_penalty(&message.command); + if cost > 0 { + this.drain_penalty(); + this.penalty += cost; + + if this.penalty > this.penalty_threshold { + let excess = this.penalty - this.penalty_threshold; + log::debug!( + "Flood penalty {}ms exceeds threshold {}ms, delaying {}ms.", + this.penalty, this.penalty_threshold, excess, + ); + this.delay = Some(Box::pin(tokio::time::sleep( + std::time::Duration::from_millis(excess), + ))); + // Buffer the message and return Pending so the delay runs. + this.buffered = Some(message); + // Register waker with the delay future. + if let Some(ref mut delay) = this.delay { + let _ = delay.as_mut().poll(cx); + } + return Poll::Pending; + } + } + } + ready!(this.try_start_send(cx, message))? + } Poll::Ready(None) => { ready!(Pin::new(&mut this.sink).poll_flush(cx))?; return Poll::Ready(Ok(())); @@ -934,6 +1013,7 @@ impl Client { let (sink, incoming) = conn.split(); let sender = Sender { tx_outgoing }; + let penalty_threshold = config.flood_penalty_threshold() as u64; Ok(Client { sender: sender.clone(), @@ -943,6 +1023,10 @@ impl Client { sink, stream: rx_outgoing, buffered: None, + penalty: 0, + penalty_threshold, + last_penalty_check: tokio::time::Instant::now(), + delay: None, }), #[cfg(test)] view, @@ -1116,6 +1200,7 @@ mod test { channels: vec!["#test".to_string(), "#test2".to_string()], user_info: Some("Testing.".to_string()), use_mock_connection: true, + flood_penalty_threshold: Some(0), ..Default::default() } } From b02e137891a9cc996b11669aa5ab46ac59cdc274 Mon Sep 17 00:00:00 2001 From: kofany Date: Sun, 8 Mar 2026 21:14:01 +0100 Subject: [PATCH 2/4] =?UTF-8?q?Improve=20flood=20penalty=20to=20match=20IR?= =?UTF-8?q?Cd=20formula=20(RFC=202813=20=C2=A75.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add message-length base cost: (1 + bytes/100) * 1000ms per message, matching the server's own flood calculation. Fix per-command penalties to closely mirror irc2.11 reference implementation: - WHO/NAMES/LIST without args: 10s (was 4s) - WHO/NAMES/LIST with args: 2s (was 4s) - NICK: 3s (was 2s) - PART: 4s (was 2s) - WHOIS/WHOWAS: 3s (was 4s) - INFO/MOTD/USERS: 5s (was 4s) - PRIVMSG/NOTICE: 2s (unchanged) - PONG/QUIT/CAP: 0s (unchanged, client-side exemption) Without the length factor, rapid sends of long messages would trigger the server's Excess Flood detection despite client-side throttling. --- src/client/data/config.rs | 11 +++--- src/client/mod.rs | 82 +++++++++++++++++++++++++++++++-------- 2 files changed, 71 insertions(+), 22 deletions(-) diff --git a/src/client/data/config.rs b/src/client/data/config.rs index 46cd1e2a..d5d7cddb 100644 --- a/src/client/data/config.rs +++ b/src/client/data/config.rs @@ -186,11 +186,12 @@ pub struct Config { #[deprecated(note = "Unimplemented. Use flood_penalty_threshold instead.")] #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub max_messages_in_burst: Option, - /// The penalty threshold in milliseconds for IRC flood protection. Each outgoing command - /// incurs a penalty cost (e.g. PRIVMSG = 2000ms, WHO = 4000ms, PONG = 0ms) that mirrors - /// the IRC server's own flood detection (RFC 2813 penalty model). When accumulated penalty - /// exceeds this threshold, outgoing messages are delayed until the penalty drains below it. - /// Penalty drains in real-time at 1ms per 1ms elapsed. + /// The penalty threshold in milliseconds for IRC flood protection (RFC 2813 §5.8). + /// Each outgoing message incurs a penalty based on both its byte length and command type, + /// matching the IRC server's own flood detection formula: + /// `(1 + message_bytes / 100) * 1000ms + command_penalty_ms`. + /// When accumulated penalty exceeds this threshold, outgoing messages are delayed until + /// the penalty drains below it. Penalty drains in real-time at 1ms per 1ms elapsed. /// /// Set to `0` to disable flood protection entirely. /// Defaults to 10000 (10 seconds), matching the standard IRCd excess flood limit. diff --git a/src/client/mod.rs b/src/client/mod.rs index c6a6149c..56af6d20 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -827,10 +827,13 @@ impl Sender { /// Future to handle outgoing messages with IRC flood protection. /// -/// Implements a penalty-based throttle modeled after irssi and IRCd (RFC 2813). Each outgoing -/// command incurs a penalty cost in milliseconds. When the accumulated penalty exceeds a -/// configurable threshold (default 10s), messages are delayed until the penalty drains below -/// the threshold. Penalty drains in real-time at 1ms per 1ms elapsed. +/// Implements a penalty-based throttle modeled after IRCd (RFC 2813 §5.8). Each outgoing +/// message incurs a penalty based on both its byte length and command type, matching the +/// server's own flood detection formula. When accumulated penalty exceeds a configurable +/// threshold (default 10s), messages are delayed until the penalty drains below it. +/// Penalty drains in real-time at 1ms per 1ms elapsed. +/// +/// Total penalty per message: `(1 + message_bytes / 100) * 1000 + command_penalty_ms` #[derive(Debug)] pub struct Outgoing { sink: SplitSink, @@ -849,29 +852,72 @@ pub struct Outgoing { impl Outgoing { /// Returns the penalty cost in milliseconds for a given IRC command. /// - /// Values mirror the irssi/IRCd penalty model (RFC 2813 §5.8): - /// - Connection control (PONG, QUIT, etc.): 0ms (never throttled) - /// - Standard messages (PRIVMSG, NOTICE, JOIN, etc.): 2000ms - /// - Expensive queries (WHO, WHOIS, LIST, etc.): 4000ms + /// Values mirror the IRCd penalty model (RFC 2813 §5.8). The server-side + /// implementation charges `(1 + message_bytes / 100)` seconds as a base + /// cost per message, plus a command-specific penalty. We replicate this + /// client-side to stay under the server's flood threshold. + /// + /// The total penalty for a message is: `base_penalty(len) + command_penalty` fn command_penalty(command: &Command) -> u64 { match command { - // Connection control — never throttled + // Connection control — never throttled client-side. The server + // does charge 1-2s for PONG, but delaying pong replies risks + // ping timeout disconnects, so we exempt these. Command::PONG(..) | Command::QUIT(..) | Command::PASS(..) => 0, // CAP negotiation — must not be throttled during registration Command::CAP(..) | Command::AUTHENTICATE(..) => 0, - // Expensive server queries - Command::WHO(..) | Command::WHOIS(..) | Command::WHOWAS(..) - | Command::LIST(..) | Command::NAMES(..) | Command::LINKS(..) - | Command::STATS(..) | Command::LUSERS(..) | Command::TRACE(..) - | Command::USERS(..) | Command::MOTD(..) => 4000, + // NICK changes incur 3s on IRCd + Command::NICK(..) => 3000, + + // PART is expensive on IRCd (4s) + Command::PART(..) => 4000, + + // WHO, NAMES, LIST without args are catastrophic on IRCd (10s). + // With args they're 2s. We can't distinguish no-arg vs arg here + // since the Option is always present in the enum, so we check + // whether the argument is None/empty. + Command::WHO(ref mask, _) => { + match mask { + None => 10_000, + Some(m) if m.is_empty() => 10_000, + _ => 2000, + } + } + Command::LIST(ref mask, _) | Command::NAMES(ref mask, _) => { + match mask { + None => 10_000, + Some(m) if m.is_empty() => 10_000, + _ => 2000, + } + } - // Everything else: standard 2s penalty + // Other expensive server queries + Command::WHOIS(..) | Command::WHOWAS(..) => 3000, + Command::LINKS(..) | Command::STATS(..) => 3000, + Command::LUSERS(..) | Command::TRACE(..) => 2000, + Command::USERS(..) | Command::MOTD(..) | Command::INFO(..) => 5000, + + // PRIVMSG/NOTICE: IRCd charges 1s per target. We approximate + // with a flat 2s since most client sends target a single entity. + Command::PRIVMSG(..) | Command::NOTICE(..) => 2000, + + // JOIN, KICK, INVITE, MODE, TOPIC, AWAY, and all others: 2s _ => 2000, } } + /// Returns the base penalty in milliseconds derived from message length. + /// + /// Mirrors the IRCd formula: `(1 + message_bytes / 100)` seconds. + /// This ensures long messages incur proportionally higher penalties, + /// matching the server's own flood calculation. + fn length_penalty(message: &Message) -> u64 { + let len = message.to_string().len() as u64; + (1 + len / 100) * 1000 + } + /// Drains accumulated penalty based on elapsed real time. fn drain_penalty(&mut self) { let now = tokio::time::Instant::now(); @@ -927,8 +973,10 @@ impl Future for Outgoing { Poll::Ready(Some(message)) => { // Apply penalty-based throttle if enabled. if this.penalty_threshold > 0 { - let cost = Self::command_penalty(&message.command); - if cost > 0 { + let cmd_cost = Self::command_penalty(&message.command); + if cmd_cost > 0 { + let len_cost = Self::length_penalty(&message); + let cost = len_cost + cmd_cost; this.drain_penalty(); this.penalty += cost; From 2d996b1bafa704d0dadf4846861e79cd214bd9c5 Mon Sep 17 00:00:00 2001 From: kofany Date: Sun, 8 Mar 2026 23:01:54 +0100 Subject: [PATCH 3/4] Fix Excess Flood: flush each message to TCP after penalty delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_send only buffers into the codec — without poll_flush the data never reaches the TCP socket. When many messages are delayed by the penalty system, they accumulate in the codec buffer and burst all at once when the stream goes idle, triggering Excess Flood on the IRCd. Add poll_flush in two places: 1. After sending a delayed-buffered message — ensures it hits TCP before we loop back for more. 2. Before returning Pending for a new delay — ensures any messages start_send'd earlier in this poll cycle are flushed before sleeping. Co-Authored-By: Claude Opus 4.6 --- src/client/mod.rs | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/client/mod.rs b/src/client/mod.rs index 56af6d20..fe572b35 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -878,20 +878,16 @@ impl Outgoing { // With args they're 2s. We can't distinguish no-arg vs arg here // since the Option is always present in the enum, so we check // whether the argument is None/empty. - Command::WHO(ref mask, _) => { - match mask { - None => 10_000, - Some(m) if m.is_empty() => 10_000, - _ => 2000, - } - } - Command::LIST(ref mask, _) | Command::NAMES(ref mask, _) => { - match mask { - None => 10_000, - Some(m) if m.is_empty() => 10_000, - _ => 2000, - } - } + Command::WHO(ref mask, _) => match mask { + None => 10_000, + Some(m) if m.is_empty() => 10_000, + _ => 2000, + }, + Command::LIST(ref mask, _) | Command::NAMES(ref mask, _) => match mask { + None => 10_000, + Some(m) if m.is_empty() => 10_000, + _ => 2000, + }, // Other expensive server queries Command::WHOIS(..) | Command::WHOWAS(..) => 3000, @@ -964,8 +960,13 @@ impl Future for Outgoing { this.drain_penalty(); } + // Send the message that was buffered during the delay, then flush + // it to TCP immediately. Without this flush, messages accumulate + // in the codec buffer across multiple delay cycles and burst all + // at once when the stream finally goes idle — causing Excess Flood. if let Some(message) = this.buffered.take() { - ready!(this.try_start_send(cx, message))? + ready!(this.try_start_send(cx, message))?; + ready!(Pin::new(&mut this.sink).poll_flush(cx))?; } loop { @@ -984,7 +985,9 @@ impl Future for Outgoing { let excess = this.penalty - this.penalty_threshold; log::debug!( "Flood penalty {}ms exceeds threshold {}ms, delaying {}ms.", - this.penalty, this.penalty_threshold, excess, + this.penalty, + this.penalty_threshold, + excess, ); this.delay = Some(Box::pin(tokio::time::sleep( std::time::Duration::from_millis(excess), @@ -995,6 +998,11 @@ impl Future for Outgoing { if let Some(ref mut delay) = this.delay { let _ = delay.as_mut().poll(cx); } + // Flush any messages already in the codec buffer before + // sleeping. Without this, a non-delayed message sent + // earlier in this poll cycle would stay buffered until + // the delay expires. + ready!(Pin::new(&mut this.sink).poll_flush(cx))?; return Poll::Pending; } } From fee0838689b8c1fc7b1b170081d7bc30417b4583 Mon Sep 17 00:00:00 2001 From: kofany Date: Mon, 9 Mar 2026 16:58:27 +0100 Subject: [PATCH 4/4] Add unit tests for flood penalty and update deprecated example Add 12 unit tests covering command_penalty(), length_penalty(), and flood_penalty_threshold config defaults. Update repeater example to use flood_penalty_threshold instead of deprecated burst fields. --- examples/repeater.rs | 3 +- src/client/mod.rs | 116 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/examples/repeater.rs b/examples/repeater.rs index 5738abcb..bcfdda99 100644 --- a/examples/repeater.rs +++ b/examples/repeater.rs @@ -7,8 +7,7 @@ async fn main() -> irc::error::Result<()> { nickname: Some("pickles".to_owned()), server: Some("chat.freenode.net".to_owned()), channels: vec!["#rust-spam".to_owned()], - burst_window_length: Some(4), - max_messages_in_burst: Some(4), + flood_penalty_threshold: Some(10_000), ..Default::default() }; diff --git a/src/client/mod.rs b/src/client/mod.rs index fe572b35..e0819d9c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1233,15 +1233,15 @@ impl Client { mod test { use std::{collections::HashMap, default::Default, thread, time::Duration}; - use super::Client; + use super::{Client, Outgoing}; #[cfg(feature = "channel-lists")] use crate::client::data::User; use crate::{ client::data::Config, error::Error, proto::{ - command::Command::{Raw, PRIVMSG}, - ChannelMode, IrcCodec, Mode, UserMode, + command::Command::{self, Raw, PRIVMSG}, + ChannelMode, IrcCodec, Message, Mode, UserMode, }, }; use anyhow::Result; @@ -2115,4 +2115,114 @@ mod test { ); Ok(()) } + + // -- Flood penalty unit tests -- + + #[test] + fn command_penalty_connection_control_is_zero() { + assert_eq!( + Outgoing::command_penalty(&Command::PONG(String::new(), None)), + 0 + ); + assert_eq!(Outgoing::command_penalty(&Command::QUIT(None)), 0); + assert_eq!(Outgoing::command_penalty(&Command::PASS(String::new())), 0); + } + + #[test] + fn command_penalty_cap_negotiation_is_zero() { + assert_eq!( + Outgoing::command_penalty(&Command::CAP( + None, + irc_proto::command::CapSubCommand::LS, + None, + None, + )), + 0 + ); + } + + #[test] + fn command_penalty_nick_is_3s() { + assert_eq!( + Outgoing::command_penalty(&Command::NICK("foo".into())), + 3000 + ); + } + + #[test] + fn command_penalty_part_is_4s() { + assert_eq!( + Outgoing::command_penalty(&Command::PART("#ch".into(), None)), + 4000 + ); + } + + #[test] + fn command_penalty_who_with_mask_is_2s() { + assert_eq!( + Outgoing::command_penalty(&Command::WHO(Some("#chan".into()), None)), + 2000 + ); + } + + #[test] + fn command_penalty_who_without_mask_is_10s() { + assert_eq!(Outgoing::command_penalty(&Command::WHO(None, None)), 10_000); + assert_eq!( + Outgoing::command_penalty(&Command::WHO(Some(String::new()), None)), + 10_000 + ); + } + + #[test] + fn command_penalty_privmsg_is_2s() { + assert_eq!( + Outgoing::command_penalty(&Command::PRIVMSG("#ch".into(), "hello".into())), + 2000 + ); + } + + #[test] + fn command_penalty_catchall_is_2s() { + assert_eq!( + Outgoing::command_penalty(&Command::JOIN("#ch".into(), None, None)), + 2000 + ); + assert_eq!( + Outgoing::command_penalty(&Command::Raw("WHO".into(), vec!["#ch".into()])), + 2000 + ); + } + + #[test] + fn length_penalty_short_message() { + // A short PRIVMSG: "PRIVMSG #test :hi\r\n" ≈ 19 bytes → (1 + 19/100) * 1000 = 1000 + let msg: Message = "PRIVMSG #test :hi\r\n".parse().unwrap(); + assert_eq!(Outgoing::length_penalty(&msg), 1000); + } + + #[test] + fn length_penalty_long_message() { + // Build a message > 200 bytes to verify scaling. + let long_text = "x".repeat(200); + let raw = format!("PRIVMSG #test :{}\r\n", long_text); + let msg: Message = raw.parse().unwrap(); + let len = msg.to_string().len() as u64; + assert_eq!(Outgoing::length_penalty(&msg), (1 + len / 100) * 1000); + // With ~216 bytes: (1 + 216/100) * 1000 = (1+2)*1000 = 3000 + assert!(Outgoing::length_penalty(&msg) >= 3000); + } + + #[test] + fn flood_penalty_threshold_disabled_by_zero() { + // Verify that test_config() disables penalty (threshold = 0). + let config = test_config(); + assert_eq!(config.flood_penalty_threshold(), 0); + } + + #[test] + fn flood_penalty_threshold_default_is_10s() { + let config = Config::default(); + assert_eq!(config.flood_penalty_threshold(), 10_000); + } }