From 910422199b2c98b9970a0e67cb8ae662a37181e2 Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 19 Aug 2026 00:45:14 +0000 Subject: [PATCH] =?UTF-8?q?perf:=20glob=5Fmatch=20takes=20a=20zero-allocat?= =?UTF-8?q?ion=20byte-wise=20path=20for=20ASCII=20operands=20(the=20norm?= =?UTF-8?q?=20for=20hostmasks/IPs/cloaks)=20instead=20of=20collecting=20tw?= =?UTF-8?q?o=20Vec=20per=20call=20=E2=80=94=20it=20runs=20per-messag?= =?UTF-8?q?e=20(filter),=20per-user=20(extbans/tline)=20and=20per-channel?= =?UTF-8?q?=20(channelban);=20non-ASCII=20still=20uses=20the=20Unicode-low?= =?UTF-8?q?ercased=20fallback,=20and=20a=20proptest=20pins=20the=20two=20p?= =?UTF-8?q?aths=20equal=20on=20ASCII?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/channels.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/channels.rs b/src/channels.rs index ac7225a..4002886 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -1453,9 +1453,50 @@ pub fn valid_chan(name: &str, maxlen: usize) -> bool { } /// Case-insensitive glob (`*` = any run, `?` = one char) — for +b mask matching. +/// +/// Runs on the message/ban/extban hot paths, so the common case — both operands +/// pure ASCII (hostmasks, IPs, cloaks) — takes a zero-allocation byte-wise path. +/// Only a non-ASCII mask or subject falls back to collecting Unicode-lowercased +/// chars. For ASCII input the two paths are identical (ASCII case-folding matches +/// `str::to_lowercase`, and one ASCII byte is one char so `?` still spans one char). pub fn glob_match(pat: &str, s: &str) -> bool { + if pat.is_ascii() && s.is_ascii() { + return glob_bytes(pat.as_bytes(), s.as_bytes()); + } let p: Vec = pat.to_lowercase().chars().collect(); let t: Vec = s.to_lowercase().chars().collect(); + glob_chars(&p, &t) +} + +/// Greedy `*`/`?` glob over ASCII bytes, case-folded inline (no allocation). +fn glob_bytes(p: &[u8], t: &[u8]) -> bool { + let (mut pi, mut ti) = (0usize, 0usize); + let mut star: Option = None; + let mut mark = 0usize; + while ti < t.len() { + if pi < p.len() && (p[pi] == b'?' || p[pi].eq_ignore_ascii_case(&t[ti])) { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == b'*' { + star = Some(pi); + mark = ti; + pi += 1; + } else if let Some(sp) = star { + pi = sp + 1; + mark += 1; + ti = mark; + } else { + return false; + } + } + while pi < p.len() && p[pi] == b'*' { + pi += 1; + } + pi == p.len() +} + +/// The same greedy glob over pre-lowercased chars (non-ASCII fallback). +fn glob_chars(p: &[char], t: &[char]) -> bool { let (mut pi, mut ti) = (0usize, 0usize); let mut star: Option = None; let mut mark = 0usize; @@ -1514,6 +1555,33 @@ mod tests { use super::*; use proptest::prelude::*; + #[test] + fn glob_basic() { + assert!(glob_match("*!*@*", "bob!user@host")); + assert!(glob_match("*.example.com", "a.b.EXAMPLE.com")); + assert!(glob_match("bob", "BOB")); + assert!(glob_match("b?b", "bXb")); + assert!(!glob_match("b?b", "bb")); + assert!(!glob_match("bob", "bobby")); + assert!(glob_match("a*z", "aXXXz")); + // non-ASCII takes the Unicode fallback and still folds case + assert!(glob_match("café*", "CAFÉ-bar")); + } + + proptest! { + // The zero-alloc ASCII fast path must agree with the char-based algorithm + // on every ASCII input. + #[test] + fn glob_ascii_fastpath_agrees( + pat in "[a-zA-Z0-9?*._@!-]{0,12}", + s in "[a-zA-Z0-9._@!-]{0,12}", + ) { + let plo: Vec = pat.to_lowercase().chars().collect(); + let tlo: Vec = s.to_lowercase().chars().collect(); + prop_assert_eq!(glob_match(&pat, &s), glob_chars(&plo, &tlo)); + } + } + proptest! { // Fuzz ban-mask normalisation: no input panics, and it's idempotent // (normalising an already-normalised mask changes nothing).