From 9d94dd5eb0bce7f00dd77eaf58000d664135c540 Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 16 Jul 2026 15:23:09 +0000 Subject: [PATCH] chanserv: support param mode locks (+f flood, +j joinflood) Mode locks were char-only, so Anope FLOOD/JOINFLOOD locks were dropped on migration. Carry a value per locked param-mode through the event log, MLOCK parsing/display, drift enforcement, compaction, and the Anope importer, so a migrated network keeps its flood protection verbatim. --- api/src/lib.rs | 12 +++++- modules/chanserv/src/clone.rs | 2 +- modules/chanserv/src/lib.rs | 71 ++++++++++++++++++++++++++++++++--- src/engine/db/channel.rs | 10 ++++- src/engine/db/event.rs | 7 ++-- src/engine/db/mod.rs | 20 +++++++++- src/engine/db/store.rs | 5 ++- src/engine/db/tests.rs | 21 +++++++++++ src/migrate.rs | 46 ++++++++++++++++++----- 9 files changed, 168 insertions(+), 26 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 69f0f39..bb74ec0 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -752,6 +752,9 @@ pub struct ChannelView { // Mode-lock: chars services keep set / unset (besides the implicit +r). pub lock_on: String, pub lock_off: String, + // Param values for locked param-modes (+f flood, +j joinflood), in the order + // their mode chars appear in `lock_on`. + pub lock_params: Vec<(char, String)>, pub access: Vec, pub akick: Vec, pub desc: String, @@ -856,6 +859,13 @@ impl ChannelView { s.push('-'); s.push_str(&self.lock_off); } + // Param modes take their argument as a trailing token, in mode-char order. + for ch in self.lock_on.chars() { + if let Some((_, param)) = self.lock_params.iter().find(|(c, _)| *c == ch) { + s.push(' '); + s.push_str(param); + } + } s } @@ -1059,7 +1069,7 @@ pub trait Store { fn session_exceptions(&self) -> Vec<(String, u32, String)>; fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError>; fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>; - fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>; + fn set_mlock(&mut self, name: &str, on: &str, off: &str, params: Vec<(char, String)>) -> Result<(), ChanError>; fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError>; fn set_url(&mut self, channel: &str, url: &str) -> Result<(), ChanError>; fn set_channel_email(&mut self, channel: &str, email: &str) -> Result<(), ChanError>; diff --git a/modules/chanserv/src/clone.rs b/modules/chanserv/src/clone.rs index 771e055..b993105 100644 --- a/modules/chanserv/src/clone.rs +++ b/modules/chanserv/src/clone.rs @@ -16,7 +16,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, "You must be the founder of both channels."); return; } - let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off); + let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off, sinfo.lock_params.clone()); for a in &sinfo.access { let _ = db.access_add(dest, &a.account, &a.level); } diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index 14d7c6d..264ff0a 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -247,8 +247,8 @@ impl Service for ChanServ { if suspended_block(me, from, chan, ctx, db) { return; } - let (on, off) = parse_mlock(&args[2..].concat()); - match db.set_mlock(chan, &on, &off) { + let (on, off, params) = parse_mlock(&args[2..]); + match db.set_mlock(chan, &on, &off, params) { Ok(()) => { if let Some(info) = db.channel(chan) { ctx.channel_mode(me, chan, &info.lock_modes()); // apply it now @@ -369,10 +369,21 @@ fn peace_blocks(me: &str, from: &Sender, chan: &str, target_uid: &str, ctx: &mut // Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is // implicit for a registered channel and is ignored here. -fn parse_mlock(spec: &str) -> (String, String) { +// Locked add-modes that carry a param (+f flood, +j joinflood): a param is +// consumed from the trailing tokens, IRC MODE order. +fn is_param_mode(c: char) -> bool { + matches!(c, 'f' | 'j') +} + +// Parse `+ntf-s ` into (on, off, params). Tokens after the mode string +// supply, in order, a value for each added param mode. +fn parse_mlock(tokens: &[&str]) -> (String, String, Vec<(char, String)>) { let (mut on, mut off) = (String::new(), String::new()); + let mut params: Vec<(char, String)> = Vec::new(); let mut adding = true; - for ch in spec.chars() { + let modes = tokens.first().copied().unwrap_or(""); + let mut rest = tokens.iter().skip(1); + for ch in modes.chars() { match ch { '+' => adding = true, '-' => adding = false, @@ -380,8 +391,17 @@ fn parse_mlock(spec: &str) -> (String, String) { m if m.is_ascii_alphabetic() => { on.retain(|c| c != m); off.retain(|c| c != m); + params.retain(|(c, _)| *c != m); if adding { on.push(m); + if is_param_mode(m) { + if let Some(&p) = rest.next() { + params.push((m, p.to_string())); + } else { + // No value supplied: drop the mode rather than lock a paramless +f. + on.pop(); + } + } } else { off.push(m); } @@ -389,10 +409,10 @@ fn parse_mlock(spec: &str) -> (String, String) { _ => {} } } - (on, off) + (on, off, params) } -// Render a channel's lock as "+on-off" for display. +// Render a channel's lock as "+on-off " for display. fn show_mlock(info: &ChannelView) -> String { let mut s = String::new(); if !info.lock_on.is_empty() { @@ -403,6 +423,45 @@ fn show_mlock(info: &ChannelView) -> String { s.push('-'); s.push_str(&info.lock_off); } + for (_, param) in &info.lock_params { + s.push(' '); + s.push_str(param); + } s } + +#[cfg(test)] +mod tests { + use super::parse_mlock; + + #[test] + fn parses_plain_modes() { + let (on, off, params) = parse_mlock(&["+nt-s"]); + assert_eq!(on, "nt"); + assert_eq!(off, "s"); + assert!(params.is_empty()); + } + + #[test] + fn captures_param_mode_value() { + let (on, off, params) = parse_mlock(&["+ntf", "mute:7:8s"]); + assert_eq!(on, "ntf"); + assert_eq!(off, ""); + assert_eq!(params, vec![('f', "mute:7:8s".to_string())]); + } + + #[test] + fn two_param_modes_consume_tokens_in_order() { + let (on, _off, params) = parse_mlock(&["+fj", "mute:7:8s", "10:6"]); + assert_eq!(on, "fj"); + assert_eq!(params, vec![('f', "mute:7:8s".to_string()), ('j', "10:6".to_string())]); + } + + #[test] + fn param_mode_without_value_is_dropped() { + let (on, _off, params) = parse_mlock(&["+ntf"]); + assert_eq!(on, "nt", "paramless +f is not locked"); + assert!(params.is_empty()); + } +} diff --git a/src/engine/db/channel.rs b/src/engine/db/channel.rs index 90913d7..50fbfce 100644 --- a/src/engine/db/channel.rs +++ b/src/engine/db/channel.rs @@ -11,7 +11,7 @@ impl Db { self.log .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .map_err(|_| ChanError::Internal)?; - self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); + self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); Ok(()) } @@ -45,15 +45,21 @@ impl Db { } /// Set the mode-lock (chars to keep set / unset) for a registered channel. + #[cfg(test)] pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> { + self.set_mlock_params(name, on, off, Vec::new()) + } + + pub fn set_mlock_params(&mut self, name: &str, on: &str, off: &str, params: Vec<(char, String)>) -> Result<(), ChanError> { let k = key(name); if !self.channels.contains_key(&k) { return Err(ChanError::NoChannel); } self.log - .append(Event::ChannelMlock { name: name.to_string(), on: on.to_string(), off: off.to_string() }) + .append(Event::ChannelMlock { name: name.to_string(), on: on.to_string(), off: off.to_string(), params: params.clone() }) .map_err(|_| ChanError::Internal)?; let c = self.channels.get_mut(&k).unwrap(); + c.lock_params = params; c.lock_on = on.to_string(); c.lock_off = off.to_string(); Ok(()) diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index 591f729..132e229 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -39,7 +39,7 @@ pub enum Event { NickUngrouped { nick: String }, ChannelRegistered { name: String, founder: String, ts: u64 }, ChannelDropped { name: String }, - ChannelMlock { name: String, on: String, off: String }, + ChannelMlock { name: String, on: String, off: String, #[serde(default)] params: Vec<(char, String)> }, ChannelAccessAdd { channel: String, account: String, level: String }, ChannelAccessDel { channel: String, account: String }, ChannelAkickAdd { channel: String, mask: String, reason: String }, @@ -429,15 +429,16 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash grouped.remove(&key(&nick)); } Event::ChannelRegistered { name, founder, ts } => { - channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); + channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); } - Event::ChannelMlock { name, on, off } => { + Event::ChannelMlock { name, on, off, params } => { if let Some(c) = channels.get_mut(&key(&name)) { c.lock_on = on; c.lock_off = off; + c.lock_params = params; } } Event::ChannelAccessAdd { channel, account, level } => { diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index b02b33d..65f11c3 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -408,6 +408,10 @@ pub struct ChannelInfo { pub lock_on: String, #[serde(default)] pub lock_off: String, + // Params for locked +modes that take one (e.g. ('f', "mute:7:8s"), ('j', "10:6")). + // Each char here is also in `lock_on`; applied and re-asserted with its param. + #[serde(default)] + pub lock_params: Vec<(char, String)>, #[serde(default)] pub access: Vec, #[serde(default)] @@ -613,6 +617,13 @@ impl ChannelInfo { s.push('-'); s.push_str(&self.lock_off); } + // Params for locked +modes that take one, in the order they appear in lock_on. + for ch in self.lock_on.chars() { + if let Some((_, p)) = self.lock_params.iter().find(|(c, _)| *c == ch) { + s.push(' '); + s.push_str(p); + } + } s } @@ -647,6 +658,13 @@ impl ChannelInfo { s.push('-'); s.push_str(&reremove); } + // A re-asserted param mode (e.g. +f) must carry its locked param. + for ch in readd.chars() { + if let Some((_, p)) = self.lock_params.iter().find(|(c, _)| *c == ch) { + s.push(' '); + s.push_str(p); + } + } Some(s) } } @@ -1087,7 +1105,7 @@ impl Db { for c in self.channels.values() { snapshot.push(Event::ChannelRegistered { name: c.name.clone(), founder: c.founder.clone(), ts: c.ts }); if !c.lock_on.is_empty() || !c.lock_off.is_empty() { - snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone() }); + snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone(), params: c.lock_params.clone() }); } for a in &c.access { snapshot.push(Event::ChannelAccessAdd { channel: c.name.clone(), account: a.account.clone(), level: a.level.clone() }); diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index b2af07a..532b4f3 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -371,8 +371,8 @@ impl Store for Db { fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> { Db::drop_channel(self, name) } - fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> { - Db::set_mlock(self, name, on, off) + fn set_mlock(&mut self, name: &str, on: &str, off: &str, params: Vec<(char, String)>) -> Result<(), ChanError> { + Db::set_mlock_params(self, name, on, off, params) } fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> { Db::set_desc(self, channel, desc) @@ -560,6 +560,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView { ts: c.ts, lock_on: c.lock_on.clone(), lock_off: c.lock_off.clone(), + lock_params: c.lock_params.clone(), access: c.access.iter().map(|a| ChanAccessView { account: a.account.clone(), level: a.level.clone() }).collect(), akick: c.akick.iter().map(|k| ChanAkickView { mask: k.mask.clone(), reason: k.reason.clone() }).collect(), desc: c.desc.clone(), diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index b9a7b27..af2f242 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -438,6 +438,27 @@ assert_eq!(db.channel("#chan").unwrap().lock_modes(), "+rnt-s", "survives reopen"); } + // A param mode lock (+f flood) carries its value through render, enforcement, + // and a reopen — the Anope FLOOD/JOINFLOOD migration must not lose it. + #[test] + fn param_mode_lock_carries_its_value() { + let p = tmp("mlock_param"); + let mut db = Db::open(&p, "local"); + db.register_channel("#chan", "alice").unwrap(); + db.set_mlock_params("#chan", "ntf", "", vec![('f', "mute:7:8s".to_string())]).unwrap(); + + let info = db.channel("#chan").unwrap(); + assert_eq!(info.lock_modes(), "+rntf mute:7:8s"); + // Removing the locked param mode re-asserts it with its value. + assert_eq!(info.enforce("-f"), Some("+f mute:7:8s".to_string())); + + drop(db); + let db = Db::open(&p, "local"); + let info = db.channel("#chan").unwrap(); + assert_eq!(info.lock_params, vec![('f', "mute:7:8s".to_string())], "param survives reopen"); + assert_eq!(info.lock_modes(), "+rntf mute:7:8s"); + } + // Access list drives join modes, is case-insensitive, and persists. #[test] fn access_list_grants_join_modes() { diff --git a/src/migrate.rs b/src/migrate.rs index bf38050..3246708 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -34,8 +34,8 @@ const SERVICE_NICKS: &[&str] = &[ "groupserv", "statserv", "infoserv", "global", "chanfix", "diceserv", "reportserv", ]; -// Anope ModeLock name -> InspIRCd mode char, for the param-less locks we can carry. -// Param modes (FLOOD/JOINFLOOD) are skipped: Echo's mlock is char-only. +// Anope ModeLock name -> InspIRCd mode char, for the param-less locks. +// Param modes (FLOOD/JOINFLOOD) are handled separately by param_mode_char. fn mode_char(name: &str) -> Option { Some(match name { "NOEXTERNAL" => 'n', @@ -56,6 +56,16 @@ fn mode_char(name: &str) -> Option { }) } +// Param-carrying mode locks: the ircd mode takes an argument, so the lock keeps +// the stored value and re-asserts `+ `. +fn param_mode_char(name: &str) -> Option { + Some(match name { + "FLOOD" => 'f', + "JOINFLOOD" => 'j', + _ => return None, + }) +} + // A non-empty, non-"None" string field (Anope stores genuine text as strings). fn field<'a>(v: &'a Value, k: &str) -> Option<&'a str> { v.get(k).and_then(Value::as_str).filter(|s| !s.is_empty() && *s != "None") @@ -249,20 +259,35 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re sum.access += 1; } - // Mode locks: aggregate the param-less +modes per channel. - let mut locks: HashMap = HashMap::new(); + // Mode locks: aggregate the +modes per channel. Param modes (+f flood, + // +j joinflood) carry their stored value so the lock re-enforces verbatim. + let mut locks: HashMap)> = HashMap::new(); for ml in records(&data, "ModeLock") { let (Some(chan), Some(name)) = (field(ml, "ci"), field(ml, "name")) else { continue }; if !flag(ml, "set") { continue; } - match mode_char(name) { - Some(ch) => locks.entry(chan.to_string()).or_default().push(ch), - None => sum.skipped.push(format!("modelock {chan} {name}: unmapped/param mode")), + if let Some(ch) = mode_char(name) { + locks.entry(chan.to_string()).or_default().0.push(ch); + } else if let Some(ch) = param_mode_char(name) { + let Some(param) = field(ml, "param") else { + sum.skipped.push(format!("modelock {chan} {name}: param mode with no value")); + continue; + }; + let entry = locks.entry(chan.to_string()).or_default(); + entry.0.push(ch); + entry.1.push((ch, param.to_string())); + } else { + sum.skipped.push(format!("modelock {chan} {name}: unmapped mode")); } } - for (chan, on) in &locks { - db.migrate_append(Event::ChannelMlock { name: chan.clone(), on: on.clone(), off: String::new() })?; + for (chan, (on, params)) in &locks { + db.migrate_append(Event::ChannelMlock { + name: chan.clone(), + on: on.clone(), + off: String::new(), + params: params.clone(), + })?; sum.mlocked += 1; } @@ -349,7 +374,8 @@ mod tests { let chan = db.channel("#chan").expect("channel migrated"); assert_eq!(chan.founder, "alice"); assert_eq!(chan.desc, "a place"); - assert_eq!(chan.lock_on, "nt", "param-less mlocks kept, FLOOD skipped"); + assert_eq!(chan.lock_on, "ntf", "param-less and param mlocks both kept"); + assert_eq!(chan.lock_params, vec![('f', "5:10".to_string())], "FLOOD param carried"); assert_eq!(chan.assigned_bot.as_deref(), Some("Botty")); assert!(chan.settings.keeptopic && chan.settings.peace, "channel SET flags migrated"); assert!(!chan.settings.signkick, "unset flags stay default (PERSIST has no equivalent, ignored)");