filter: pluggable pattern engine (glob|regex) selected by filter_engine config; regex/glob backends behind a Matcher trait

This commit is contained in:
Jean Chevronnet 2026-08-22 17:36:46 +00:00
parent 73817eddde
commit 4357a59c7a
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
5 changed files with 143 additions and 20 deletions

View file

@ -2,7 +2,6 @@
//! and, on a hit, an action is taken. The rule set lives in `Server.ext`, the
//! `FILTER` command manages it, and the `on_pre_message` hook enforces it.
use crate::channels::glob_match;
use crate::command::{CmdResult, Command};
use crate::module::{ModResult, Module};
use crate::numeric::ERR_NOPRIVILEGES;
@ -11,12 +10,29 @@ use crate::xline::{parse_duration, XKind};
use crate::Uid;
/// One filter rule.
#[derive(Clone)]
pub struct SpamFilter {
pub pattern: String, // glob matched against message text
pub pattern: String, // matched against message text via `engine`
pub engine: String, // pattern engine: "glob" (default) or "regex"
pub action: String, // block | silent | kill | kline | gline | zline
pub duration: u64, // ban length for the *line actions (seconds; 0 = permanent)
pub reason: String,
matcher: Box<dyn crate::modules::pattern::Matcher>, // compiled `pattern` for `engine`
}
impl SpamFilter {
/// Build a rule, compiling `pattern` with `engine` (`glob` or `regex`). Errors
/// (as a message string) if the engine is unknown or a regex is invalid, so a
/// bad rule is refused when set rather than silently never matching.
pub fn new(
pattern: String,
engine: String,
action: String,
duration: u64,
reason: String,
) -> Result<SpamFilter, String> {
let matcher = crate::modules::pattern::compile(&engine, &pattern)?;
Ok(SpamFilter { pattern, engine, action, duration, reason, matcher })
}
}
/// The rule set, stored in `Server.ext`.
@ -24,11 +40,11 @@ pub struct SpamFilter {
pub struct Filters(pub Vec<SpamFilter>);
impl Filters {
/// The (action, reason, duration) of the first rule whose glob matches `text`.
/// The (action, reason, duration) of the first rule whose pattern matches `text`.
fn hit(&self, text: &str) -> Option<(String, String, u64)> {
self.0
.iter()
.find(|f| glob_match(&f.pattern, text))
.find(|f| f.matcher.is_match(text))
.map(|f| (f.action.clone(), f.reason.clone(), f.duration))
}
}
@ -121,7 +137,7 @@ impl Command for FilterCmd {
.map(|f| {
f.0.iter()
.map(|r| {
format!("{} {} {} :{}", r.pattern, r.action, r.duration, r.reason)
format!("{} [{}] {} {} :{}", r.pattern, r.engine, r.action, r.duration, r.reason)
})
.collect()
})
@ -163,18 +179,42 @@ impl Command for FilterCmd {
.unwrap_or_else(|| "Filtered".to_string()),
),
};
// Compile with the configured engine (glob default), rejecting a
// bad rule now rather than having it silently never match.
let engine = s.conf("filter_engine").unwrap_or("glob").to_string();
let filter = match SpamFilter::new(pattern.clone(), engine.clone(), action.clone(), duration, reason) {
Ok(f) => f,
Err(e) => {
s.send(uid, format!(":{} NOTICE {nick} :FILTER rejected ({e})", s.name));
return CmdResult::Fail;
}
};
let f = s.ext.get_or_insert_with::<Filters>(Filters::default);
f.0.retain(|r| r.pattern != pattern);
f.0.push(SpamFilter {
pattern: pattern.clone(),
action: action.clone(),
duration,
reason,
});
s.snotice_c('f', &format!("{nick} added FILTER {pattern} (action={action})"));
f.0.push(filter);
s.snotice_c('f', &format!("{nick} added FILTER {pattern} (engine={engine} action={action})"));
}
}
}
CmdResult::Ok
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_matches_by_engine() {
let mut filters = Filters::default();
filters.0.push(SpamFilter::new("*buy now*".into(), "glob".into(), "block".into(), 0, "spam".into()).unwrap());
filters.0.push(SpamFilter::new("free.*money".into(), "regex".into(), "kill".into(), 0, "scam".into()).unwrap());
// glob is case-insensitive wildcard matching
assert!(filters.hit("hey BUY NOW cheap").is_some(), "glob rule matches");
// regex is a full expression (unanchored substring search)
assert_eq!(filters.hit("get free money here").map(|(a, _, _)| a), Some("kill".into()), "regex rule matches");
assert!(filters.hit("an ordinary message").is_none(), "no rule matches clean text");
// an invalid regex is refused when the rule is built
assert!(SpamFilter::new("(oops".into(), "regex".into(), "block".into(), 0, "x".into()).is_err());
}
}

View file

@ -56,6 +56,7 @@ pub mod operlevels;
pub mod operprefix;
pub mod opertypes;
pub mod password_hash;
pub mod pattern;
pub mod permchannels;
pub mod profilelink;
pub mod randquote;

76
src/modules/pattern.rs Normal file
View file

@ -0,0 +1,76 @@
//! Pluggable pattern engines: match text against a pattern using either simple
//! wildcard globs or full regular expressions, selected by name from config.
//!
//! Modules that test user input against admin-set patterns (e.g. the spam
//! `filter`) compile a pattern once via [`compile`] — rejecting a bad one at set
//! time — then call [`Matcher::is_match`] on the hot path, so the engine choice
//! costs a single virtual call and no per-message compilation. New engines slot in
//! by adding an arm to [`compile`] and a name to [`ENGINES`].
use crate::channels::glob_match;
use crate::regex::Regex;
/// A compiled pattern that can test text for a match. `Send` so it can live in
/// `Server.ext` alongside the rest of a module's state.
pub trait Matcher: Send {
fn is_match(&self, text: &str) -> bool;
}
/// Wildcard glob (`*` / `?`), the default — case-insensitive like the rest of the
/// ircd's mask matching.
struct GlobMatcher(String);
impl Matcher for GlobMatcher {
fn is_match(&self, text: &str) -> bool {
glob_match(&self.0, text)
}
}
/// Full regular expression (the same engine RLINE uses).
struct RegexMatcher(Regex);
impl Matcher for RegexMatcher {
fn is_match(&self, text: &str) -> bool {
self.0.is_match(text)
}
}
/// The engine names selectable in config (for help text / error messages).
pub const ENGINES: &[&str] = &["glob", "regex"];
/// Compile `pattern` for the named `engine`: `glob` (default, wildcards) or
/// `regex` (a full regular expression). Errors on an unknown engine or an invalid
/// regex, so a bad rule is refused when it's set rather than silently never matching.
pub fn compile(engine: &str, pattern: &str) -> Result<Box<dyn Matcher>, String> {
match engine {
"glob" | "" => Ok(Box::new(GlobMatcher(pattern.to_string()))),
"regex" => Ok(Box::new(RegexMatcher(Regex::new(pattern)?))),
other => Err(format!(
"unknown pattern engine '{other}' (use one of: {})",
ENGINES.join(", ")
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_and_regex_engines_match() {
let g = compile("glob", "*spam*").unwrap();
assert!(g.is_match("this is SPAM here")); // wildcards, case-insensitive
assert!(!g.is_match("clean text"));
let r = compile("regex", "spam.*bot").unwrap();
assert!(r.is_match("a spam sending bot")); // full regex (case-sensitive, substring)
assert!(!r.is_match("nothing to see"));
// default (empty) engine is glob
assert!(compile("", "*x*").unwrap().is_match("axb"));
}
#[test]
fn bad_engine_or_regex_is_rejected() {
assert!(compile("pcre", ".*").is_err()); // unknown engine
assert!(compile("regex", "(unclosed").is_err()); // invalid regex
}
}

View file

@ -19,6 +19,7 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
.map(|r| {
obj(&[
("pattern", qstr(&r.pattern)),
("engine", qstr(&r.engine)),
("reason", qstr(&r.reason)),
("action", qstr(&r.action)),
("duration", r.duration.to_string()),
@ -36,16 +37,17 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
let action = json::get_str(params, "action").unwrap_or_else(|| "block".into());
let reason = json::get_str(params, "reason").unwrap_or_else(|| "Set via RPC".into());
let duration = json::get_num::<u64>(params, "duration").unwrap_or(0);
// engine: an explicit param, else the configured default, else glob.
let engine = json::get_str(params, "engine")
.or_else(|| s.conf("filter_engine").map(str::to_string))
.unwrap_or_else(|| "glob".to_string());
let filter = SpamFilter::new(pattern, engine, action, duration, reason)
.map_err(|e| RpcError::invalid_params(&e))?;
let set = s.ext.get_or_insert_with::<Filters>(Filters::default);
if set.0.iter().any(|f| f.pattern == pattern) {
if set.0.iter().any(|f| f.pattern == filter.pattern) {
return Err(RpcError::not_found("filter already exists"));
}
set.0.push(SpamFilter {
pattern,
action,
duration,
reason,
});
set.0.push(filter);
Ok(obj(&[("result", "true".into())]))
}
"del" => {