rline: native regex engine (thompson nfa, no backtracking) + RLINE regex bans (registration/enforce/stats R/matchonnickchange)
This commit is contained in:
parent
b76fbfdb79
commit
56d3774647
8 changed files with 786 additions and 1 deletions
|
|
@ -126,6 +126,10 @@ amu_target = both
|
||||||
# requireident = yes # refuse clients whose ident can't be confirmed
|
# requireident = yes # refuse clients whose ident can't be confirmed
|
||||||
# ident_timeout = 5 # seconds to wait for the ident reply
|
# ident_timeout = 5 # seconds to wait for the ident reply
|
||||||
|
|
||||||
|
# --- RLINE: the /RLINE <regex> [<duration>] :<reason> oper command bans users whose
|
||||||
|
# "nick!user@host realname" matches a regex (native engine; no config to enable).
|
||||||
|
# rline_matchonnickchange = yes # also re-check the R-lines when a user changes nick
|
||||||
|
|
||||||
# --- security groups: securitygroup = <name> [criteria...]
|
# --- security groups: securitygroup = <name> [criteria...]
|
||||||
# criteria: public tls insecure account unregistered oper exclude-oper
|
# criteria: public tls insecure account unregistered oper exclude-oper
|
||||||
# bot exclude-bot webirc exclude-webirc mask=<glob> exclude=<glob>
|
# bot exclude-bot webirc exclude-webirc mask=<glob> exclude=<glob>
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ impl Command for Stats {
|
||||||
s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper"));
|
s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
'k' | 'g' | 'z' | 'e' | 'q' | 's' | 'S' => {
|
'k' | 'g' | 'z' | 'e' | 'q' | 's' | 'S' | 'R' => {
|
||||||
let kind = match letter {
|
let kind = match letter {
|
||||||
'k' => XKind::Kline,
|
'k' => XKind::Kline,
|
||||||
'g' => XKind::Gline,
|
'g' => XKind::Gline,
|
||||||
|
|
@ -251,6 +251,7 @@ impl Command for Stats {
|
||||||
'e' => XKind::Eline,
|
'e' => XKind::Eline,
|
||||||
'q' => XKind::Qline,
|
'q' => XKind::Qline,
|
||||||
'S' => XKind::Svshold,
|
'S' => XKind::Svshold,
|
||||||
|
'R' => XKind::Rline,
|
||||||
_ => XKind::Shun,
|
_ => XKind::Shun,
|
||||||
};
|
};
|
||||||
let rows: Vec<String> = s
|
let rows: Vec<String> = s
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
Box::new(Shun),
|
Box::new(Shun),
|
||||||
Box::new(Qline),
|
Box::new(Qline),
|
||||||
Box::new(Cban),
|
Box::new(Cban),
|
||||||
|
Box::new(Rline),
|
||||||
Box::new(Connect),
|
Box::new(Connect),
|
||||||
Box::new(ChgHost),
|
Box::new(ChgHost),
|
||||||
Box::new(ChgIdent),
|
Box::new(ChgIdent),
|
||||||
|
|
@ -743,6 +744,56 @@ impl Command for Cban {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RLINE — ban users whose `nick!user@host realname` matches a regular expression.
|
||||||
|
/// `RLINE <regex> [<duration>] :<reason>` adds; `RLINE <regex>` removes.
|
||||||
|
struct Rline;
|
||||||
|
impl Command for Rline {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"RLINE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let pattern = params[0].clone();
|
||||||
|
let nick = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if params.len() < 2 {
|
||||||
|
let word = if s.remove_xline(XKind::Rline, &pattern) {
|
||||||
|
"removed"
|
||||||
|
} else {
|
||||||
|
"not found"
|
||||||
|
};
|
||||||
|
s.send(
|
||||||
|
uid,
|
||||||
|
format!(":{} NOTICE {nick} :R-line {word}: {pattern}", s.name),
|
||||||
|
);
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::regex::Regex::new(&pattern) {
|
||||||
|
s.send(
|
||||||
|
uid,
|
||||||
|
format!(":{} NOTICE {nick} :Invalid RLINE regex: {e}", s.name),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let dur = parse_duration(¶ms[1]).unwrap_or(0);
|
||||||
|
let reason = params
|
||||||
|
.get(2)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "No reason given".to_string());
|
||||||
|
s.add_xline(XKind::Rline, &pattern, dur, &nick, &reason);
|
||||||
|
s.enforce_rline(&pattern, &reason);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// NICKLOCK — force a user's nick and lock it so they can't change it.
|
/// NICKLOCK — force a user's nick and lock it so they can't change it.
|
||||||
/// `NICKLOCK <nick> <newnick>`; opers/services still can.
|
/// `NICKLOCK <nick> <newnick>`; opers/services still can.
|
||||||
struct NickLock;
|
struct NickLock;
|
||||||
|
|
|
||||||
|
|
@ -501,6 +501,24 @@ impl Command for Nick {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.set_nick(uid, newnick);
|
s.set_nick(uid, newnick);
|
||||||
|
// RLINE matchonnickchange: re-test the R-lines against the new identity
|
||||||
|
if s.conf_bool("rline_matchonnickchange", false) {
|
||||||
|
let info = s.users.get(&uid).map(|u| {
|
||||||
|
(
|
||||||
|
u.nick.clone(),
|
||||||
|
u.ident.clone(),
|
||||||
|
u.host.clone(),
|
||||||
|
u.addr.ip().to_string(),
|
||||||
|
u.realname.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if let Some((nk, id, ho, ip, rn)) = info {
|
||||||
|
if let Some(reason) = s.matched_rline(&nk, &id, &ho, &ip, &rn) {
|
||||||
|
s.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||||
|
s.remove_user(uid, &reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
CmdResult::Ok
|
CmdResult::Ok
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
src/ircd.rs
18
src/ircd.rs
|
|
@ -435,6 +435,24 @@ impl Ircd {
|
||||||
self.server.remove_user(uid, &reason);
|
self.server.remove_user(uid, &reason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// R-line: refuse a user whose nick!user@host realname matches a banned regex
|
||||||
|
// (checked after ident is finalised so the matchtext is the real username).
|
||||||
|
let rl = {
|
||||||
|
let u = &self.server.users[&uid];
|
||||||
|
self.server.matched_rline(
|
||||||
|
&u.nick,
|
||||||
|
&u.ident,
|
||||||
|
&u.host,
|
||||||
|
&u.addr.ip().to_string(),
|
||||||
|
&u.realname,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if let Some(reason) = rl {
|
||||||
|
self.server
|
||||||
|
.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||||
|
self.server.remove_user(uid, &reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// connectclass: verify the class password and apply its on-connect modes
|
// connectclass: verify the class password and apply its on-connect modes
|
||||||
if let Some(reason) = crate::modules::connclass::on_register(&mut self.server, uid) {
|
if let Some(reason) = crate::modules::connclass::on_register(&mut self.server, uid) {
|
||||||
self.server
|
self.server
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ pub mod mode;
|
||||||
pub mod module;
|
pub mod module;
|
||||||
pub mod modules;
|
pub mod modules;
|
||||||
pub mod numeric;
|
pub mod numeric;
|
||||||
|
pub mod regex;
|
||||||
pub mod resolver;
|
pub mod resolver;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod socketengine;
|
pub mod socketengine;
|
||||||
|
|
|
||||||
640
src/regex.rs
Normal file
640
src/regex.rs
Normal file
|
|
@ -0,0 +1,640 @@
|
||||||
|
//! A small, dependency-free regular-expression engine (Thompson NFA). Linear time
|
||||||
|
//! in the input length with no backtracking, so a hostile pattern/input pair can't
|
||||||
|
//! blow up the single-threaded core — important because operators drive it via
|
||||||
|
//! RLINE (see [`crate::xline`]).
|
||||||
|
//!
|
||||||
|
//! Supported: literals, `.`, character classes `[...]`/`[^...]` with ranges and the
|
||||||
|
//! `\d \w \s \D \W \S` shorthands, the quantifiers `* + ? {n} {n,} {n,m}` (all
|
||||||
|
//! treated greedily — greediness can't change whether a match exists), alternation
|
||||||
|
//! `|`, grouping `(...)` and non-capturing `(?:...)`, the anchors `^` `$`, and the
|
||||||
|
//! escapes `\n \t \r` plus `\<char>` for a literal. Matching is an unanchored
|
||||||
|
//! search (use `^`/`$` to anchor); there are no capture groups or backreferences.
|
||||||
|
|
||||||
|
/// One member test inside a character class (or a bare shorthand like `\d`).
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum ClassItem {
|
||||||
|
Range(char, char),
|
||||||
|
Digit,
|
||||||
|
NotDigit,
|
||||||
|
Word,
|
||||||
|
NotWord,
|
||||||
|
Space,
|
||||||
|
NotSpace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClassItem {
|
||||||
|
fn matches(&self, c: char) -> bool {
|
||||||
|
match self {
|
||||||
|
ClassItem::Range(lo, hi) => *lo <= c && c <= *hi,
|
||||||
|
ClassItem::Digit => c.is_ascii_digit(),
|
||||||
|
ClassItem::NotDigit => !c.is_ascii_digit(),
|
||||||
|
ClassItem::Word => c.is_ascii_alphanumeric() || c == '_',
|
||||||
|
ClassItem::NotWord => !(c.is_ascii_alphanumeric() || c == '_'),
|
||||||
|
ClassItem::Space => c.is_ascii_whitespace(),
|
||||||
|
ClassItem::NotSpace => !c.is_ascii_whitespace(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pattern AST.
|
||||||
|
enum Ast {
|
||||||
|
Empty,
|
||||||
|
Char(char),
|
||||||
|
Any,
|
||||||
|
Class(Vec<ClassItem>, bool), // items + whether the whole class is negated
|
||||||
|
Start,
|
||||||
|
End,
|
||||||
|
Concat(Vec<Ast>),
|
||||||
|
Alt(Vec<Ast>),
|
||||||
|
Star(Box<Ast>),
|
||||||
|
Plus(Box<Ast>),
|
||||||
|
Quest(Box<Ast>),
|
||||||
|
Repeat(Box<Ast>, u32, Option<u32>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compiled instruction. Split/Jmp/Start/End are the zero-width (epsilon) ones;
|
||||||
|
/// Char/Any/Class consume a character; Match accepts.
|
||||||
|
enum Inst {
|
||||||
|
Char(char),
|
||||||
|
Any,
|
||||||
|
Class(Vec<ClassItem>, bool),
|
||||||
|
Split(usize, usize),
|
||||||
|
Jmp(usize),
|
||||||
|
Start,
|
||||||
|
End,
|
||||||
|
Match,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DEPTH: usize = 200; // parser nesting guard
|
||||||
|
const MAX_REPEAT: u32 = 1000; // {n,m} bound cap
|
||||||
|
const MAX_PROG: usize = 200_000; // compiled-program size cap
|
||||||
|
|
||||||
|
pub struct Regex {
|
||||||
|
prog: Vec<Inst>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Regex {
|
||||||
|
/// Compile `pattern`, or return a human-readable error.
|
||||||
|
pub fn new(pattern: &str) -> Result<Regex, String> {
|
||||||
|
let mut p = Parser {
|
||||||
|
chars: pattern.chars().collect(),
|
||||||
|
pos: 0,
|
||||||
|
depth: 0,
|
||||||
|
};
|
||||||
|
let ast = p.parse_alt()?;
|
||||||
|
if p.pos != p.chars.len() {
|
||||||
|
return Err(format!("unexpected `{}`", p.chars[p.pos]));
|
||||||
|
}
|
||||||
|
// Unanchored search: a `.*` prefix lets the match begin anywhere while the
|
||||||
|
// real anchors (^/$) still see the true string position.
|
||||||
|
let mut c = Compiler {
|
||||||
|
prog: vec![Inst::Split(1, 3), Inst::Any, Inst::Jmp(0)],
|
||||||
|
};
|
||||||
|
c.compile(&ast)?;
|
||||||
|
c.prog.push(Inst::Match);
|
||||||
|
Ok(Regex { prog: c.prog })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `text` contains a match.
|
||||||
|
pub fn is_match(&self, text: &str) -> bool {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let n = chars.len();
|
||||||
|
let mut seen = vec![0u32; self.prog.len()];
|
||||||
|
let mut gen = 1u32;
|
||||||
|
let mut clist: Vec<usize> = Vec::new();
|
||||||
|
let mut nlist: Vec<usize> = Vec::new();
|
||||||
|
self.add_thread(&mut clist, &mut seen, gen, 0, 0, n);
|
||||||
|
for pos in 0..=n {
|
||||||
|
gen += 1;
|
||||||
|
nlist.clear();
|
||||||
|
for i in 0..clist.len() {
|
||||||
|
let pc = clist[i];
|
||||||
|
let hit = match &self.prog[pc] {
|
||||||
|
Inst::Match => return true,
|
||||||
|
Inst::Char(c) => pos < n && chars[pos] == *c,
|
||||||
|
Inst::Any => pos < n && chars[pos] != '\n',
|
||||||
|
Inst::Class(items, neg) => {
|
||||||
|
pos < n && class_hit(items, *neg, chars[pos])
|
||||||
|
}
|
||||||
|
_ => false, // epsilon insts never reach clist
|
||||||
|
};
|
||||||
|
if hit {
|
||||||
|
self.add_thread(&mut nlist, &mut seen, gen, pc + 1, pos + 1, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut clist, &mut nlist);
|
||||||
|
if pos == n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Epsilon-closure: add `start` and everything reachable from it by
|
||||||
|
/// Split/Jmp/anchor transitions to `list`, deduped by `seen`/`gen`. Iterative so
|
||||||
|
/// a pathological pattern can't overflow the stack.
|
||||||
|
fn add_thread(
|
||||||
|
&self,
|
||||||
|
list: &mut Vec<usize>,
|
||||||
|
seen: &mut [u32],
|
||||||
|
gen: u32,
|
||||||
|
start: usize,
|
||||||
|
pos: usize,
|
||||||
|
n: usize,
|
||||||
|
) {
|
||||||
|
let mut stack = vec![start];
|
||||||
|
while let Some(pc) = stack.pop() {
|
||||||
|
if seen[pc] == gen {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen[pc] = gen;
|
||||||
|
match &self.prog[pc] {
|
||||||
|
Inst::Jmp(x) => stack.push(*x),
|
||||||
|
Inst::Split(x, y) => {
|
||||||
|
stack.push(*y);
|
||||||
|
stack.push(*x);
|
||||||
|
}
|
||||||
|
Inst::Start => {
|
||||||
|
if pos == 0 {
|
||||||
|
stack.push(pc + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Inst::End => {
|
||||||
|
if pos == n {
|
||||||
|
stack.push(pc + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => list.push(pc), // Char/Any/Class/Match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class_hit(items: &[ClassItem], negated: bool, c: char) -> bool {
|
||||||
|
let any = items.iter().any(|it| it.matches(c));
|
||||||
|
any != negated
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- compiler ----------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Compiler {
|
||||||
|
prog: Vec<Inst>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compiler {
|
||||||
|
fn compile(&mut self, ast: &Ast) -> Result<(), String> {
|
||||||
|
if self.prog.len() > MAX_PROG {
|
||||||
|
return Err("pattern too large".into());
|
||||||
|
}
|
||||||
|
match ast {
|
||||||
|
Ast::Empty => {}
|
||||||
|
Ast::Char(c) => self.prog.push(Inst::Char(*c)),
|
||||||
|
Ast::Any => self.prog.push(Inst::Any),
|
||||||
|
Ast::Class(items, neg) => self.prog.push(Inst::Class(items.clone(), *neg)),
|
||||||
|
Ast::Start => self.prog.push(Inst::Start),
|
||||||
|
Ast::End => self.prog.push(Inst::End),
|
||||||
|
Ast::Concat(v) => {
|
||||||
|
for a in v {
|
||||||
|
self.compile(a)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ast::Alt(v) => {
|
||||||
|
let mut jmps = Vec::new();
|
||||||
|
for (i, a) in v.iter().enumerate() {
|
||||||
|
if i + 1 < v.len() {
|
||||||
|
let split = self.prog.len();
|
||||||
|
self.prog.push(Inst::Split(0, 0));
|
||||||
|
let l1 = self.prog.len();
|
||||||
|
self.compile(a)?;
|
||||||
|
jmps.push(self.prog.len());
|
||||||
|
self.prog.push(Inst::Jmp(0));
|
||||||
|
let l2 = self.prog.len();
|
||||||
|
self.prog[split] = Inst::Split(l1, l2);
|
||||||
|
} else {
|
||||||
|
self.compile(a)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let end = self.prog.len();
|
||||||
|
for j in jmps {
|
||||||
|
self.prog[j] = Inst::Jmp(end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ast::Star(a) => {
|
||||||
|
let l1 = self.prog.len();
|
||||||
|
self.prog.push(Inst::Split(0, 0));
|
||||||
|
let body = self.prog.len();
|
||||||
|
self.compile(a)?;
|
||||||
|
self.prog.push(Inst::Jmp(l1));
|
||||||
|
let l3 = self.prog.len();
|
||||||
|
self.prog[l1] = Inst::Split(body, l3);
|
||||||
|
}
|
||||||
|
Ast::Plus(a) => {
|
||||||
|
let l1 = self.prog.len();
|
||||||
|
self.compile(a)?;
|
||||||
|
let split = self.prog.len();
|
||||||
|
self.prog.push(Inst::Split(0, 0));
|
||||||
|
let l3 = self.prog.len();
|
||||||
|
self.prog[split] = Inst::Split(l1, l3);
|
||||||
|
}
|
||||||
|
Ast::Quest(a) => {
|
||||||
|
let split = self.prog.len();
|
||||||
|
self.prog.push(Inst::Split(0, 0));
|
||||||
|
let l1 = self.prog.len();
|
||||||
|
self.compile(a)?;
|
||||||
|
let l2 = self.prog.len();
|
||||||
|
self.prog[split] = Inst::Split(l1, l2);
|
||||||
|
}
|
||||||
|
Ast::Repeat(a, min, max) => {
|
||||||
|
for _ in 0..*min {
|
||||||
|
self.compile(a)?;
|
||||||
|
}
|
||||||
|
match max {
|
||||||
|
None => self.compile(&Ast::Star(clone_ast(a)))?,
|
||||||
|
Some(mx) => {
|
||||||
|
for _ in *min..*mx {
|
||||||
|
self.compile(&Ast::Quest(clone_ast(a)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.prog.len() > MAX_PROG {
|
||||||
|
return Err("pattern too large".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deep-clone an AST node (only needed to expand bounded repetition).
|
||||||
|
fn clone_ast(a: &Ast) -> Box<Ast> {
|
||||||
|
Box::new(match a {
|
||||||
|
Ast::Empty => Ast::Empty,
|
||||||
|
Ast::Char(c) => Ast::Char(*c),
|
||||||
|
Ast::Any => Ast::Any,
|
||||||
|
Ast::Class(i, n) => Ast::Class(i.clone(), *n),
|
||||||
|
Ast::Start => Ast::Start,
|
||||||
|
Ast::End => Ast::End,
|
||||||
|
Ast::Concat(v) => Ast::Concat(v.iter().map(|x| *clone_ast(x)).collect()),
|
||||||
|
Ast::Alt(v) => Ast::Alt(v.iter().map(|x| *clone_ast(x)).collect()),
|
||||||
|
Ast::Star(x) => Ast::Star(clone_ast(x)),
|
||||||
|
Ast::Plus(x) => Ast::Plus(clone_ast(x)),
|
||||||
|
Ast::Quest(x) => Ast::Quest(clone_ast(x)),
|
||||||
|
Ast::Repeat(x, lo, hi) => Ast::Repeat(clone_ast(x), *lo, *hi),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parser ------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Parser {
|
||||||
|
chars: Vec<char>,
|
||||||
|
pos: usize,
|
||||||
|
depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parser {
|
||||||
|
fn peek(&self) -> Option<char> {
|
||||||
|
self.chars.get(self.pos).copied()
|
||||||
|
}
|
||||||
|
fn at(&self, off: usize) -> Option<char> {
|
||||||
|
self.chars.get(self.pos + off).copied()
|
||||||
|
}
|
||||||
|
fn bump(&mut self) -> Option<char> {
|
||||||
|
let c = self.peek();
|
||||||
|
if c.is_some() {
|
||||||
|
self.pos += 1;
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
fn eat(&mut self, c: char) -> bool {
|
||||||
|
if self.peek() == Some(c) {
|
||||||
|
self.pos += 1;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_alt(&mut self) -> Result<Ast, String> {
|
||||||
|
let mut alts = vec![self.parse_concat()?];
|
||||||
|
while self.eat('|') {
|
||||||
|
alts.push(self.parse_concat()?);
|
||||||
|
}
|
||||||
|
Ok(if alts.len() == 1 {
|
||||||
|
alts.pop().unwrap()
|
||||||
|
} else {
|
||||||
|
Ast::Alt(alts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_concat(&mut self) -> Result<Ast, String> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
while let Some(c) = self.peek() {
|
||||||
|
if c == '|' || c == ')' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
items.push(self.parse_repeat()?);
|
||||||
|
}
|
||||||
|
Ok(match items.len() {
|
||||||
|
0 => Ast::Empty,
|
||||||
|
1 => items.pop().unwrap(),
|
||||||
|
_ => Ast::Concat(items),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_repeat(&mut self) -> Result<Ast, String> {
|
||||||
|
let atom = self.parse_atom()?;
|
||||||
|
let node = match self.peek() {
|
||||||
|
Some('*') => {
|
||||||
|
self.pos += 1;
|
||||||
|
Ast::Star(Box::new(atom))
|
||||||
|
}
|
||||||
|
Some('+') => {
|
||||||
|
self.pos += 1;
|
||||||
|
Ast::Plus(Box::new(atom))
|
||||||
|
}
|
||||||
|
Some('?') => {
|
||||||
|
self.pos += 1;
|
||||||
|
Ast::Quest(Box::new(atom))
|
||||||
|
}
|
||||||
|
Some('{') => match self.try_bound()? {
|
||||||
|
Some((min, max)) => Ast::Repeat(Box::new(atom), min, max),
|
||||||
|
None => return Ok(atom), // a lone `{` is a literal (parsed next round)
|
||||||
|
},
|
||||||
|
_ => return Ok(atom),
|
||||||
|
};
|
||||||
|
self.eat('?'); // accept and ignore a lazy modifier
|
||||||
|
Ok(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to parse `{n}` / `{n,}` / `{n,m}` at the cursor. Returns None (without
|
||||||
|
/// consuming) if it isn't a well-formed bound, so `{` falls back to a literal.
|
||||||
|
fn try_bound(&mut self) -> Result<Option<(u32, Option<u32>)>, String> {
|
||||||
|
let save = self.pos;
|
||||||
|
self.pos += 1; // consume '{'
|
||||||
|
let min = self.parse_int();
|
||||||
|
let (min, max) = match self.peek() {
|
||||||
|
Some('}') if min.is_some() => {
|
||||||
|
self.pos += 1;
|
||||||
|
let m = min.unwrap();
|
||||||
|
(m, Some(m))
|
||||||
|
}
|
||||||
|
Some(',') => {
|
||||||
|
self.pos += 1;
|
||||||
|
let max = self.parse_int();
|
||||||
|
if !self.eat('}') {
|
||||||
|
self.pos = save;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
(min.unwrap_or(0), max)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.pos = save;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if min > MAX_REPEAT || max.map(|m| m > MAX_REPEAT).unwrap_or(false) {
|
||||||
|
return Err("repetition count too large".into());
|
||||||
|
}
|
||||||
|
if let Some(m) = max {
|
||||||
|
if m < min {
|
||||||
|
return Err("bad repetition range".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some((min, max)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_int(&mut self) -> Option<u32> {
|
||||||
|
let start = self.pos;
|
||||||
|
while self.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
|
||||||
|
self.pos += 1;
|
||||||
|
}
|
||||||
|
if self.pos == start {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.chars[start..self.pos]
|
||||||
|
.iter()
|
||||||
|
.collect::<String>()
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_atom(&mut self) -> Result<Ast, String> {
|
||||||
|
self.depth += 1;
|
||||||
|
if self.depth > MAX_DEPTH {
|
||||||
|
return Err("pattern too deeply nested".into());
|
||||||
|
}
|
||||||
|
let r = match self.bump() {
|
||||||
|
Some('(') => {
|
||||||
|
if self.peek() == Some('?') {
|
||||||
|
// non-capturing (?:...) or a flag group (?i) — we only match, so
|
||||||
|
// just skip to the ':' (or ')') and treat the body plainly.
|
||||||
|
self.pos += 1;
|
||||||
|
while let Some(c) = self.peek() {
|
||||||
|
self.pos += 1;
|
||||||
|
if c == ':' || c == ')' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let inner = self.parse_alt()?;
|
||||||
|
if !self.eat(')') {
|
||||||
|
return Err("unmatched `(`".into());
|
||||||
|
}
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
Some('[') => self.parse_class()?,
|
||||||
|
Some('.') => Ast::Any,
|
||||||
|
Some('^') => Ast::Start,
|
||||||
|
Some('$') => Ast::End,
|
||||||
|
Some('\\') => self.parse_escape()?,
|
||||||
|
Some(c) if c == '*' || c == '+' || c == '?' => {
|
||||||
|
return Err("nothing to repeat".into())
|
||||||
|
}
|
||||||
|
Some(c) => Ast::Char(c),
|
||||||
|
None => Ast::Empty,
|
||||||
|
};
|
||||||
|
self.depth -= 1;
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_escape(&mut self) -> Result<Ast, String> {
|
||||||
|
match self.bump() {
|
||||||
|
Some('d') => Ok(Ast::Class(vec![ClassItem::Digit], false)),
|
||||||
|
Some('D') => Ok(Ast::Class(vec![ClassItem::NotDigit], false)),
|
||||||
|
Some('w') => Ok(Ast::Class(vec![ClassItem::Word], false)),
|
||||||
|
Some('W') => Ok(Ast::Class(vec![ClassItem::NotWord], false)),
|
||||||
|
Some('s') => Ok(Ast::Class(vec![ClassItem::Space], false)),
|
||||||
|
Some('S') => Ok(Ast::Class(vec![ClassItem::NotSpace], false)),
|
||||||
|
Some('n') => Ok(Ast::Char('\n')),
|
||||||
|
Some('t') => Ok(Ast::Char('\t')),
|
||||||
|
Some('r') => Ok(Ast::Char('\r')),
|
||||||
|
Some(c) => Ok(Ast::Char(c)),
|
||||||
|
None => Err("trailing backslash".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_class(&mut self) -> Result<Ast, String> {
|
||||||
|
let negated = self.eat('^');
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut first = true;
|
||||||
|
loop {
|
||||||
|
match self.peek() {
|
||||||
|
None => return Err("unterminated `[`".into()),
|
||||||
|
Some(']') if !first => {
|
||||||
|
self.pos += 1;
|
||||||
|
return Ok(Ast::Class(items, negated));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
// one class member: a shorthand, or a (possibly ranged) char
|
||||||
|
if self.peek() == Some('\\') {
|
||||||
|
self.pos += 1;
|
||||||
|
match self.bump() {
|
||||||
|
Some('d') => items.push(ClassItem::Digit),
|
||||||
|
Some('D') => items.push(ClassItem::NotDigit),
|
||||||
|
Some('w') => items.push(ClassItem::Word),
|
||||||
|
Some('W') => items.push(ClassItem::NotWord),
|
||||||
|
Some('s') => items.push(ClassItem::Space),
|
||||||
|
Some('S') => items.push(ClassItem::NotSpace),
|
||||||
|
Some(c) => {
|
||||||
|
let lo = match c {
|
||||||
|
'n' => '\n',
|
||||||
|
't' => '\t',
|
||||||
|
'r' => '\r',
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
self.class_maybe_range(&mut items, lo)?;
|
||||||
|
}
|
||||||
|
None => return Err("unterminated `[`".into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let lo = self.bump().unwrap();
|
||||||
|
self.class_maybe_range(&mut items, lo)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Having read class-member start `lo`, consume an optional `-hi` range.
|
||||||
|
fn class_maybe_range(&mut self, items: &mut Vec<ClassItem>, lo: char) -> Result<(), String> {
|
||||||
|
if self.peek() == Some('-') && self.at(1).map(|c| c != ']').unwrap_or(false) {
|
||||||
|
self.pos += 1; // consume '-'
|
||||||
|
let hi = if self.peek() == Some('\\') {
|
||||||
|
self.pos += 1;
|
||||||
|
match self.bump() {
|
||||||
|
Some('n') => '\n',
|
||||||
|
Some('t') => '\t',
|
||||||
|
Some('r') => '\r',
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Err("unterminated `[`".into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.bump().ok_or("unterminated `[`")?
|
||||||
|
};
|
||||||
|
if hi < lo {
|
||||||
|
return Err("bad character range".into());
|
||||||
|
}
|
||||||
|
items.push(ClassItem::Range(lo, hi));
|
||||||
|
} else {
|
||||||
|
items.push(ClassItem::Range(lo, lo));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn m(pat: &str, text: &str) -> bool {
|
||||||
|
Regex::new(pat).unwrap().is_match(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn literals_and_search() {
|
||||||
|
assert!(m("abc", "xxabcyy"));
|
||||||
|
assert!(!m("abc", "ab_c"));
|
||||||
|
assert!(m("", "anything")); // empty matches
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anchors() {
|
||||||
|
assert!(m("^abc", "abcdef"));
|
||||||
|
assert!(!m("^abc", "zabc"));
|
||||||
|
assert!(m("abc$", "zzabc"));
|
||||||
|
assert!(!m("abc$", "abcz"));
|
||||||
|
assert!(m("^a.c$", "axc"));
|
||||||
|
assert!(!m("^a.c$", "axcd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quantifiers() {
|
||||||
|
assert!(m("^a*$", ""));
|
||||||
|
assert!(m("^a*$", "aaaa"));
|
||||||
|
assert!(!m("^a+$", ""));
|
||||||
|
assert!(m("^ab?c$", "ac"));
|
||||||
|
assert!(m("^ab?c$", "abc"));
|
||||||
|
assert!(m("^a{2,3}$", "aa"));
|
||||||
|
assert!(m("^a{2,3}$", "aaa"));
|
||||||
|
assert!(!m("^a{2,3}$", "a"));
|
||||||
|
assert!(!m("^a{2,3}$", "aaaa"));
|
||||||
|
assert!(m("^x{3}$", "xxx"));
|
||||||
|
assert!(m("^x{2,}$", "xxxxx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classes_and_shorthands() {
|
||||||
|
assert!(m("^[a-z]+$", "hello"));
|
||||||
|
assert!(!m("^[a-z]+$", "Hello"));
|
||||||
|
assert!(m("^[^0-9]+$", "abc"));
|
||||||
|
assert!(!m("^[^0-9]+$", "ab3"));
|
||||||
|
assert!(m(r"^\d{3}$", "123"));
|
||||||
|
assert!(!m(r"^\d{3}$", "12a"));
|
||||||
|
assert!(m(r"^\w+$", "a_1B"));
|
||||||
|
assert!(!m(r"^\w+$", "a b"));
|
||||||
|
assert!(m(r"foo\s+bar", "foo bar"));
|
||||||
|
assert!(m("^[]a]$", "]")); // leading ] is literal
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alternation_and_groups() {
|
||||||
|
assert!(m("^(cat|dog|bird)$", "dog"));
|
||||||
|
assert!(!m("^(cat|dog)$", "cow"));
|
||||||
|
assert!(m("^(ab)+$", "ababab"));
|
||||||
|
assert!(m("^(?:xy)+z$", "xyxyz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn realname_style_bans() {
|
||||||
|
// nick!user@host realname matchtext, like RLINE uses
|
||||||
|
let rx = Regex::new(r"^.+!.+@.+ .*spamword.*$").unwrap();
|
||||||
|
assert!(rx.is_match("bad!user@host.com i sell spamword cheap"));
|
||||||
|
assert!(!rx.is_match("good!user@host.com hello there"));
|
||||||
|
// ip-ish host anchor
|
||||||
|
assert!(Regex::new(r"@(10|192)\.").unwrap().is_match("n!u@192.168.0.1 real"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dot_excludes_newline_and_escapes() {
|
||||||
|
assert!(!m("^a.b$", "a\nb"));
|
||||||
|
assert!(m(r"^a\.b$", "a.b"));
|
||||||
|
assert!(!m(r"^a\.b$", "axb"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_patterns_rejected() {
|
||||||
|
assert!(Regex::new("(unclosed").is_err());
|
||||||
|
assert!(Regex::new("[unterminated").is_err());
|
||||||
|
assert!(Regex::new("*nothing").is_err());
|
||||||
|
assert!(Regex::new(r"a\").is_err());
|
||||||
|
assert!(Regex::new("a{2,1}").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_catastrophic_backtracking() {
|
||||||
|
// the classic ReDoS pattern; a backtracking engine hangs, the NFA is linear
|
||||||
|
let rx = Regex::new("^(a+)+$").unwrap();
|
||||||
|
assert!(!rx.is_match(&("a".repeat(40) + "!")));
|
||||||
|
assert!(rx.is_match(&"a".repeat(40)));
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/xline.rs
52
src/xline.rs
|
|
@ -17,6 +17,7 @@ pub enum XKind {
|
||||||
Qline, // a reserved/forbidden nick glob
|
Qline, // a reserved/forbidden nick glob
|
||||||
Cban, // a forbidden channel-name glob
|
Cban, // a forbidden channel-name glob
|
||||||
Svshold, // a services-reserved nick glob (like Qline, but services-owned)
|
Svshold, // a services-reserved nick glob (like Qline, but services-owned)
|
||||||
|
Rline, // a regex over "nick!user@host realname"
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XKind {
|
impl XKind {
|
||||||
|
|
@ -30,6 +31,7 @@ impl XKind {
|
||||||
XKind::Qline => "Q",
|
XKind::Qline => "Q",
|
||||||
XKind::Cban => "CBAN",
|
XKind::Cban => "CBAN",
|
||||||
XKind::Svshold => "SVSHOLD",
|
XKind::Svshold => "SVSHOLD",
|
||||||
|
XKind::Rline => "R",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +46,7 @@ impl XKind {
|
||||||
"Q" => XKind::Qline,
|
"Q" => XKind::Qline,
|
||||||
"CBAN" => XKind::Cban,
|
"CBAN" => XKind::Cban,
|
||||||
"SVSHOLD" => XKind::Svshold,
|
"SVSHOLD" => XKind::Svshold,
|
||||||
|
"R" => XKind::Rline,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -189,6 +192,55 @@ impl Server {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reason an R-line's regex matches this user, if any. RLINE tests the
|
||||||
|
/// pattern against both `nick!user@host realname` and the ip form. A stored
|
||||||
|
/// pattern that no longer compiles is skipped.
|
||||||
|
pub fn matched_rline(
|
||||||
|
&self,
|
||||||
|
nick: &str,
|
||||||
|
ident: &str,
|
||||||
|
host: &str,
|
||||||
|
ip: &str,
|
||||||
|
real: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let n = now();
|
||||||
|
let hostform = format!("{nick}!{ident}@{host} {real}");
|
||||||
|
let ipform = format!("{nick}!{ident}@{ip} {real}");
|
||||||
|
self.xlines
|
||||||
|
.iter()
|
||||||
|
.find(|x| {
|
||||||
|
x.kind == XKind::Rline
|
||||||
|
&& (x.expires == 0 || x.expires > n)
|
||||||
|
&& crate::regex::Regex::new(&x.mask)
|
||||||
|
.map(|re| re.is_match(&hostform) || re.is_match(&ipform))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.map(|x| format!("R-lined: {}", x.reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill every registered local user matched by R-line `pattern` (called after an
|
||||||
|
/// RLINE is added, since the generic enforce sweep is glob- not regex-based).
|
||||||
|
pub fn enforce_rline(&mut self, pattern: &str, reason: &str) {
|
||||||
|
let Ok(re) = crate::regex::Regex::new(pattern) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let victims: Vec<Uid> = self
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, u)| u.registered)
|
||||||
|
.filter(|(_, u)| {
|
||||||
|
let host = format!("{}!{}@{} {}", u.nick, u.ident, u.host, u.realname);
|
||||||
|
let ip = format!("{}!{}@{} {}", u.nick, u.ident, u.addr.ip(), u.realname);
|
||||||
|
re.is_match(&host) || re.is_match(&ip)
|
||||||
|
})
|
||||||
|
.map(|(&uid, _)| uid)
|
||||||
|
.collect();
|
||||||
|
for uid in victims {
|
||||||
|
self.send(uid, format!("ERROR :Closing link: (R-lined: {reason})"));
|
||||||
|
self.remove_user(uid, &format!("R-lined: {reason}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Add (or replace) an x-line, then kill every connected user it matches.
|
/// Add (or replace) an x-line, then kill every connected user it matches.
|
||||||
pub fn add_xline(
|
pub fn add_xline(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue