diff --git a/api/src/lib.rs b/api/src/lib.rs index 6e6ebd0..15c8e23 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -572,6 +572,8 @@ pub trait Store { fn badword_del(&mut self, channel: &str, pattern: &str) -> Result; fn badword_clear(&mut self, channel: &str) -> Result; fn badwords(&self, channel: &str) -> Vec; + // Dry-run the content kickers against a line; Some(reason) if it would kick. + fn kicker_test(&self, channel: &str, text: &str) -> Option; fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>; fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option) -> Result<(), ChanError>; fn unsuspend_channel(&mut self, channel: &str) -> Result; diff --git a/botserv/src/kick.rs b/botserv/src/kick.rs index aa59365..02e983d 100644 --- a/botserv/src/kick.rs +++ b/botserv/src/kick.rs @@ -25,6 +25,21 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } + // TEST dry-runs the content kickers against a line, kicking nobody — so an + // op can tune caps%/badword patterns safely. + if kind.eq_ignore_ascii_case("TEST") { + if args.len() < 4 { + ctx.notice(me, from.uid, "Syntax: KICK <#channel> TEST "); + return; + } + let text = args[3..].join(" "); + match db.kicker_test(chan, &text) { + Some(reason) => ctx.notice(me, from.uid, format!("That line \x02would be kicked\x02: {reason}")), + None => ctx.notice(me, from.uid, "That line trips no content kicker (flood/repeat depend on timing and repetition, so they aren't tested here)."), + } + return; + } + let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() { Some("ON") | Some("TRUE") => true, Some("OFF") | Some("FALSE") => false, diff --git a/src/engine/db.rs b/src/engine/db.rs index 8b41a39..cbd2d66 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -5,6 +5,17 @@ use std::path::PathBuf; // Ceiling on a single compiled badword regex, so one pattern can't consume huge // memory. Shared by add-time validation and the engine's match-time build. pub const BADWORD_SIZE_LIMIT: usize = 1 << 20; + +/// Compile a channel's badword patterns into one RegexSet. Case-insensitive by +/// default (Anope's default; a pattern can opt out with `(?-i)`), size-limited, +/// and tolerant of a bad entry so one pattern can't disable the whole set. +pub fn build_badword_set(patterns: &[String]) -> regex::RegexSet { + regex::RegexSetBuilder::new(patterns) + .case_insensitive(true) + .size_limit(BADWORD_SIZE_LIMIT) + .build() + .unwrap_or_else(|_| regex::RegexSet::empty()) +} use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use argon2::password_hash::rand_core::{OsRng, RngCore}; @@ -1515,6 +1526,19 @@ impl Db { self.channels.get(&key(channel)).map_or(&[], |c| c.badwords.as_slice()) } + /// Dry-run the content kickers against a line: the reason it would be kicked, + /// or None. Flood/repeat are stateful/time-based and are not simulated here. + pub fn kicker_test(&self, channel: &str, text: &str) -> Option { + let c = self.channels.get(&key(channel))?; + if let Some(reason) = c.kickers.violation(text) { + return Some(reason.to_string()); + } + if c.kickers.badwords && !c.badwords.is_empty() && build_badword_set(&c.badwords).is_match(text) { + return Some("matches a badword".to_string()); + } + None + } + // Persist a new badword list (whole-list event) and apply it. fn write_badwords(&mut self, channel: &str, k: &str, list: Vec) -> Result<(), ChanError> { self.log @@ -2194,6 +2218,9 @@ impl Store for Db { fn badwords(&self, channel: &str) -> Vec { Db::badwords(self, channel).to_vec() } + fn kicker_test(&self, channel: &str, text: &str) -> Option { + Db::kicker_test(self, channel, text) + } fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> { Db::set_channel_topic(self, channel, topic) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 846e0ba..aaeae86 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1013,7 +1013,7 @@ impl Engine { if reason.is_none() && k.badwords && !c.badwords.is_empty() { let rev = c.badwords_rev; if self.badword_cache.get(channel).map(|cb| cb.rev) != Some(rev) { - let set = build_badword_set(&c.badwords); + let set = db::build_badword_set(&c.badwords); self.badword_cache.insert(channel.to_string(), CachedBadwords { rev, set }); } if self.badword_cache.get(channel).unwrap().set.is_match(text) { @@ -1193,17 +1193,6 @@ fn ci_hash(text: &str) -> u64 { h.finish() } -// Compile a channel's badword patterns into one RegexSet. Case-insensitive by -// default (Anope's default; a pattern can opt out with `(?-i)`), size-limited, -// and tolerant of a bad entry so one pattern can't disable the whole set. -fn build_badword_set(patterns: &[String]) -> regex::RegexSet { - regex::RegexSetBuilder::new(patterns) - .case_insensitive(true) - .size_limit(db::BADWORD_SIZE_LIMIT) - .build() - .unwrap_or_else(|_| regex::RegexSet::empty()) -} - fn login_plain(b64: &str, db: &Db) -> Option { let raw = STANDARD.decode(b64).ok()?; let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect(); @@ -2365,6 +2354,21 @@ mod tests { assert!(!say(&mut e, "[unclosed bracket text").iter().any(|a| matches!(a, NetAction::Kick { .. })), "bad pattern not stored"); } + // KICK TEST dry-runs the content kickers against a line without kicking. + #[test] + fn botserv_kick_test_dry_run() { + let (mut e, _p) = kicker_fixture("bskicktest"); + let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); + bs(&mut e, "KICK #c CAPS ON"); + bs(&mut e, "BADWORDS #c ADD fr[ao]g"); + bs(&mut e, "KICK #c BADWORDS ON"); + let says = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); + + assert!(says(&bs(&mut e, "KICK #c TEST HELLO EVERYONE LOUD"), "would be kicked"), "caps flagged"); + assert!(says(&bs(&mut e, "KICK #c TEST i love frogs"), "would be kicked"), "badword flagged"); + assert!(says(&bs(&mut e, "KICK #c TEST hello there"), "trips no content kicker"), "clean line ok"); + } + // Times-to-ban: after TTB kicks the bot bans the user, and BANEXPIRE lifts // the ban once it elapses (swept on the next event). #[test]