filter: pluggable pattern engine (glob|regex) selected by filter_engine config; regex/glob backends behind a Matcher trait
This commit is contained in:
parent
73817eddde
commit
4357a59c7a
5 changed files with 143 additions and 20 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
76
src/modules/pattern.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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" => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue