chanserv: support param mode locks (+f flood, +j joinflood)
Some checks failed
CI / check (push) Has been cancelled

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.
This commit is contained in:
Jean Chevronnet 2026-07-16 15:23:09 +00:00
parent 3f97a66dbb
commit 9d94dd5eb0
No known key found for this signature in database
9 changed files with 168 additions and 26 deletions

View file

@ -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(())

View file

@ -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<String, Account>, 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 } => {

View file

@ -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<ChanAccess>,
#[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() });

View file

@ -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(),

View file

@ -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() {