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

@ -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<char> {
Some(match name {
"NOEXTERNAL" => 'n',
@ -56,6 +56,16 @@ fn mode_char(name: &str) -> Option<char> {
})
}
// Param-carrying mode locks: the ircd mode takes an argument, so the lock keeps
// the stored value and re-asserts `+<char> <value>`.
fn param_mode_char(name: &str) -> Option<char> {
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<String, String> = 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<String, (String, Vec<(char, String)>)> = 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)");