From 2f6fd41465d6233d74efa0bce505b77a1bb03373 Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 15:43:16 +0000 Subject: [PATCH] chanserv: SOP protect tier over AOP (+ao), founder owner (+qo); resolve op access by capability not mode string --- api/src/lib.rs | 69 +++++++++++++++++++++++++++------ modules/chanserv/src/enforce.rs | 4 +- modules/chanserv/src/updown.rs | 8 ++-- src/engine/db/mod.rs | 18 +++++++-- src/engine/db/tests.rs | 2 +- src/engine/mod.rs | 11 ++++-- src/engine/tests.rs | 64 ++++++++++++++++++++++++------ 7 files changed, 139 insertions(+), 37 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 5d90eaa..750277e 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -632,7 +632,7 @@ pub const ACCESS_FLAGS: &str = "foOhvtiasg"; // The capabilities an access `level` confers (founder is layered on by callers). #[derive(Debug, Clone, Copy, Default)] pub struct Caps { - pub auto: Option<&'static str>, // auto status mode on join: +o / +h / +v + pub auto: Option<&'static str>, // auto status mode on join: +qo / +ao / +o / +h / +v pub op: bool, // moderation: kick / ban / op / mode / akick pub topic: bool, pub invite: bool, @@ -647,10 +647,12 @@ pub struct Caps { // entries and new flag entries coexist. pub fn level_caps(level: &str) -> Caps { match level { - "founder" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, set: true, greet: true, rank: 3 }, - // SOP = op plus access-list management; AOP = op; HOP = halfop; VOP = voice. - // These are the tiers the XOP shortcuts store; ordered high to low. - "sop" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, rank: 2, ..Caps::default() }, + "founder" => Caps { auto: Some("+qo"), op: true, topic: true, invite: true, access: true, set: true, greet: true, rank: 3 }, + // The XOP tiers, ordered high to low. Each op-and-above tier carries op + // (+o, the @ prefix) plus its rank marker: founder owner (+q, ~), sop + // protect (+a, &). So AOP and above all show @, while sop outranks aop and + // founder outranks sop. SOP additionally holds access-list management. + "sop" => Caps { auto: Some("+ao"), op: true, topic: true, invite: true, access: true, rank: 2, ..Caps::default() }, "op" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, rank: 2, ..Caps::default() }, "halfop" => Caps { auto: Some("+h"), rank: 1, ..Caps::default() }, "voice" => Caps { auto: Some("+v"), rank: 1, ..Caps::default() }, @@ -749,6 +751,20 @@ pub fn access_role(level: &str) -> AccessRole { } } +/// Build the argument for a channel MODE that applies the prefix modes in +/// `modes` (e.g. "+ao", "-aohv") to `target`. Every prefix mode takes the target +/// as its parameter, so the target is repeated once per mode letter — a single +/// "+o" gives "+o nick", "+ao" gives "+ao nick nick". +pub fn status_mode(modes: &str, target: &str) -> String { + let letters = modes.chars().filter(|c| c.is_ascii_alphabetic()).count(); + let mut out = String::from(modes); + for _ in 0..letters { + out.push(' '); + out.push_str(target); + } + out +} + // The granular group-membership flags (GroupServ), same primitive, different // letters: F founder, f modify the member/flags list, i invite members, s set // group options, c the group may be granted channel access, m group memos. @@ -758,11 +774,24 @@ impl Caps { // Combine two capability sets (a direct access entry and a group's), taking // the union of privileges, the higher rank, and the strongest auto-mode. pub fn union(self, other: Caps) -> Caps { - let rank_of = |m: Option<&'static str>| match m { - Some("+o") => 3, - Some("+h") => 2, - Some("+v") => 1, - _ => 0, + // Strength of an auto status mode, owner > protect > op > halfop > voice, + // robust to letter order and to combined modes like "+qo" / "+ao". + let rank_of = |m: Option<&'static str>| { + m.map_or(0, |s| { + if s.contains('q') { + 5 + } else if s.contains('a') { + 4 + } else if s.contains('o') { + 3 + } else if s.contains('h') { + 2 + } else if s.contains('v') { + 1 + } else { + 0 + } + }) }; let auto = if rank_of(self.auto) >= rank_of(other.auto) { self.auto } else { other.auto }; Caps { @@ -1660,11 +1689,27 @@ mod tests { assert!(t.topic && !t.op && t.auto.is_none()); assert!(level_caps("a").access && level_caps("f").set); - // The four XOP tiers are distinct: SOP is AOP plus access-list rights, - // HOP is its own halfop tier. + // Each op-and-above tier carries op (+o) plus its rank marker, so all show + // @: founder +qo (~@), SOP +ao (&@), AOP +o (@), HOP +h. assert!(level_caps("sop").op && level_caps("sop").access); + assert_eq!(level_caps("founder").auto, Some("+qo")); + assert_eq!(level_caps("sop").auto, Some("+ao"), "SOP is op + protect"); + assert_eq!(level_caps("op").auto, Some("+o")); assert!(level_caps("op").op && !level_caps("op").access); assert_eq!(level_caps("halfop").auto, Some("+h")); + + // Combined, the stronger auto mode wins: SOP (+ao) over a voice group (+v), + // and founder (+qo) over everything. + assert_eq!(level_caps("sop").union(level_caps("voice")).auto, Some("+ao")); + assert_eq!(level_caps("founder").union(level_caps("sop")).auto, Some("+qo")); + } + + #[test] + fn status_mode_repeats_the_target_per_letter() { + assert_eq!(status_mode("+o", "X"), "+o X"); + assert_eq!(status_mode("+ao", "X"), "+ao X X", "protect + op each take the nick"); + // DOWN strips the whole ladder, so the target repeats once per letter. + assert_eq!(status_mode("-qaohv", "X"), "-qaohv X X X X X"); } #[test] diff --git a/modules/chanserv/src/enforce.rs b/modules/chanserv/src/enforce.rs index 3cfe650..57413a4 100644 --- a/modules/chanserv/src/enforce.rs +++ b/modules/chanserv/src/enforce.rs @@ -1,5 +1,5 @@ use echo_api::Store; -use echo_api::{Sender, ServiceCtx}; +use echo_api::{status_mode, Sender, ServiceCtx}; use echo_api::NetView; // ENFORCE <#channel>: re-apply the channel's settings to everyone present — @@ -19,7 +19,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: let members: Vec = net.channel_members(chan); for uid in members { match net.account_of(&uid).and_then(|a| info.join_mode(a)) { - Some(m) => ctx.channel_mode(me, chan, &format!("{m} {uid}")), + Some(m) => ctx.channel_mode(me, chan, &status_mode(m, &uid)), None => { let nick = net.nick_of(&uid).unwrap_or("*"); let host = net.host_of(&uid).unwrap_or("*"); diff --git a/modules/chanserv/src/updown.rs b/modules/chanserv/src/updown.rs index 49bc53d..f9ed3a6 100644 --- a/modules/chanserv/src/updown.rs +++ b/modules/chanserv/src/updown.rs @@ -1,4 +1,4 @@ -use echo_api::{NetView, Sender, ServiceCtx, Store}; +use echo_api::{status_mode, NetView, Sender, ServiceCtx, Store}; // UP <#channel>: (re)apply the status mode your access entitles you to. // DOWN <#channel>: drop your channel status modes. `up` selects which. @@ -22,14 +22,14 @@ pub fn handle(me: &str, from: &Sender, up: bool, args: &[&str], ctx: &mut Servic if up { match info.join_mode(account) { Some(mode) => { - ctx.channel_mode(me, chan, &format!("{mode} {}", from.uid)); + ctx.channel_mode(me, chan, &status_mode(mode, from.uid)); ctx.notice(me, from.uid, format!("Your status in \x02{chan}\x02 has been applied.")); } None => ctx.notice(me, from.uid, format!("You have no status access in \x02{chan}\x02.")), } } else { - // Strip op and voice; the ircd ignores any mode you don't currently hold. - ctx.channel_mode(me, chan, &format!("-ov {} {}", from.uid, from.uid)); + // Strip owner, admin, op, halfop and voice; the ircd ignores any you don't hold. + ctx.channel_mode(me, chan, &status_mode("-qaohv", from.uid)); ctx.notice(me, from.uid, format!("Your status in \x02{chan}\x02 has been removed.")); } } diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 263da4c..53de277 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -601,11 +601,11 @@ impl KickerSettings { } impl ChannelInfo { - /// The status mode to give `account` on join, if any: +o for the founder and - /// access-list ops, +v for voices. + /// The status mode to give `account` on join, if any: +qo for the founder, + /// +ao/+o for access-list ops, +v for voices. pub fn join_mode(&self, account: &str) -> Option<&'static str> { if self.founder.eq_ignore_ascii_case(account) { - return Some("+o"); + return echo_api::level_caps("founder").auto; } self.access .iter() @@ -613,6 +613,18 @@ impl ChannelInfo { .and_then(|a| echo_api::level_caps(&a.level).auto) } + /// Whether `account` holds op-level (moderation) access — the founder, or an + /// access entry whose level resolves to op. A capability check, so it stays + /// right for SOP, whose join mode is "+ao" rather than literally "+o". + pub fn is_op(&self, account: &str) -> bool { + self.founder.eq_ignore_ascii_case(account) + || self + .access + .iter() + .find(|a| a.account.eq_ignore_ascii_case(account)) + .is_some_and(|a| echo_api::level_caps(&a.level).op) + } + /// The matching auto-kick entry for `hostmask` (nick!user@host), if any. pub fn akick_match(&self, hostmask: &str) -> Option<&ChanAkick> { self.akick.iter().find(|k| glob_match(&k.mask, hostmask)) diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index 491a15f..4b50927 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -496,7 +496,7 @@ db.access_add("#c", "bob", "voice").unwrap(); let info = db.channel("#c").unwrap(); - assert_eq!(info.join_mode("boss"), Some("+o")); // founder + assert_eq!(info.join_mode("boss"), Some("+qo")); // founder: owner + op assert_eq!(info.join_mode("ALICE"), Some("+o")); // op, case-insensitive assert_eq!(info.join_mode("bob"), Some("+v")); // voice assert_eq!(info.join_mode("nobody"), None); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 3c4ecb3..18e4c03 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1176,6 +1176,9 @@ impl Engine { // Group-aware: a member of a group that holds channel access gets // the group's status mode too. let mode = account.as_deref().and_then(|a| self.db.channel_join_mode(&channel, a)); + // Op-level access is a capability, not a specific mode string — a + // SOP auto-gets "+ao", so never compare the mode against "+o". + let has_op_access = account.as_deref().is_some_and(|a| self.db.channel(&channel).is_some_and(|c| c.is_op(a))); let entrymsg = self.db.channel(&channel).map(|c| c.entrymsg.clone()).filter(|m| !m.is_empty()); // Computed before the match below moves `channel` into its actions. let greet = self.greet_on_join(&channel, account.as_deref()); @@ -1185,7 +1188,7 @@ impl Engine { let is_bot = self.bot_uids.values().any(|b| b == &uid); // SECUREOPS: a user who arrives opped (FJOIN prefix) but lacks op-level // access loses it, unless we're about to grant it to them anyway. - if op && mode != Some("+o") && !is_bot && self.db.channel(&channel).is_some_and(|c| c.settings.secureops) { + if op && !has_op_access && !is_bot && self.db.channel(&channel).is_some_and(|c| c.settings.secureops) { out.push(NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: format!("-o {uid}") }); } // RESTRICTED: only users with access (or opers) may be in the channel. @@ -1207,7 +1210,7 @@ impl Engine { out.push(NetAction::Notice { from: from.clone(), to: uid.clone(), text: msg }); } if autoop { - out.push(NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") }); + out.push(NetAction::ChannelMode { from, channel, modes: echo_api::status_mode(m, &uid) }); } } // No access: an auto-kick match is banned and kicked, else greeted. @@ -1259,7 +1262,7 @@ impl Engine { // SECUREOPS: a user who gains +o without op-level access loses it. if op { if let Some(c) = self.db.channel(&channel) { - if c.settings.secureops && !self.network.account_of(&uid).is_some_and(|a| c.join_mode(a) == Some("+o")) { + if c.settings.secureops && !self.network.account_of(&uid).is_some_and(|a| c.is_op(a)) { let from = self.chan_service.clone().unwrap_or_default(); return vec![NetAction::ChannelMode { from, channel, modes: format!("-o {uid}") }]; } @@ -1278,7 +1281,7 @@ impl Engine { NetEvent::TopicChange { channel, setter, topic } => { let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone())); // The setter may change a locked topic only with op-level access. - let authorized = self.network.account_of(&setter).and_then(|a| self.db.channel(&channel).map(|c| c.join_mode(a) == Some("+o"))).unwrap_or(false); + let authorized = self.network.account_of(&setter).and_then(|a| self.db.channel(&channel).map(|c| c.is_op(a))).unwrap_or(false); match info { // TOPICLOCK + unauthorised: put the kept topic back. Some((_, true, stored)) if !authorized => { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index f5d35b7..4f1cfb9 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -553,7 +553,7 @@ // Founder alice joins: the auto-op is sourced from the bot, not ChanServ. let join = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false }); let src = join.iter().find_map(|a| match a { - NetAction::ChannelMode { from, modes, .. } if modes.contains("+o") => Some(from.clone()), + NetAction::ChannelMode { from, modes, .. } if modes.contains('o') => Some(from.clone()), _ => None, }).expect("alice auto-opped"); assert_eq!(src, botuid, "auto-op fronts as the assigned bot"); @@ -1336,13 +1336,55 @@ let cs = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: text.into() }); let out = cs(&mut e, "UP #c"); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")), "UP applies the founder's +o: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB")), "UP applies the founder's +qo: {out:?}"); let out = cs(&mut e, "DOWN #c"); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-ov"))), "DOWN strips status: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-qaohv"))), "DOWN strips status: {out:?}"); let out = cs(&mut e, "UP #nope"); assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("isn't registered"))), "unknown channel refused: {out:?}"); } + // A SOP auto-gets op + protect (+ao, so it shows @ and outranks AOP) on join, + // and secureops never strips it — op access is a capability, not literal "+o". + #[test] + fn sop_join_gets_protect_and_op_and_survives_secureops() { + use echo_chanserv::ChanServ; + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-sopjoin.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + db.register("mik", "sesame", None).unwrap(); + db.register_channel("#c", "alice").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(ChanServ { uid: "42SAAAAAB".into() }), + ], + db, + ); + for (uid, nick) in [("000AAAAAA", "alice"), ("000AAAAAB", "mik")] { + e.handle(NetEvent::UserConnect { uid: uid.into(), nick: nick.into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + } + // Founder makes mik a SOP and turns secureops on. + e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAB".into(), text: "SOP #c ADD mik".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAB".into(), text: "SET #c SECUREOPS ON".into() }); + + // On join mik is granted op + protect (+ao, the nick once per mode). + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false }); + assert!( + out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+ao 000AAAAAB 000AAAAAB")), + "SOP auto-gets op + protect: {out:?}" + ); + // Arriving already opped, secureops must NOT strip a SOP's op. + let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true }); + assert!( + !out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-o"))), + "secureops keeps a SOP opped: {out:?}" + ); + } + // DEOP with no target deops yourself; PEACE (no acting against equal-or-higher // access) must NOT block that — you can always drop your own status. #[test] @@ -1974,7 +2016,7 @@ ); e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); - let opped = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")); + let opped = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB")); // Default: the founder is auto-opped on join. assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op on by default"); @@ -2006,7 +2048,7 @@ e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() }); - let opped = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")); + let opped = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB")); let notice = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n))); // Default: founder is auto-opped (channel AUTOOP on, account AUTOOP on). @@ -4841,9 +4883,9 @@ assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "+b *!*@bad.host")), "{out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::Kick { channel, uid, reason, .. } if channel == "#c" && uid == "000AAAAAC" && reason.starts_with("begone"))), "{out:?}"); - // The founder joining is never auto-kicked (they get +o instead). + // The founder joining is never auto-kicked (they get +qo instead). let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false }); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("+o"))), "{out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.contains('o'))), "{out:?}"); } // ChanServ SET: description and founder transfer, founder-gated. @@ -4926,15 +4968,15 @@ assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c ADD carol"), "Added")); assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c LIST"), "carol")); - // ENFORCE: alice present gets +o, the akick-matching guest is kicked. + // ENFORCE: alice (founder) present gets +qo, the akick-matching guest is kicked. e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false }); to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@h2 out"); let out = to_cs(&mut e, "000AAAAAB", "ENFORCE #c"); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")), "{out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB")), "{out:?}"); assert!(out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAC")), "{out:?}"); // SYNC is an alias for ENFORCE — same re-apply of access modes. let out = to_cs(&mut e, "000AAAAAB", "SYNC #c"); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")), "sync alias: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+qo 000AAAAAB 000AAAAAB")), "sync alias: {out:?}"); // GETKEY: reflects a tracked key change. e.handle(NetEvent::ChannelKey { channel: "#c".into(), key: Some("s3cret".into()) }); @@ -5007,7 +5049,7 @@ e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#x".into(), op: false }); - assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#x" && modes == "+o 000AAAAAB")), "founder opped: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#x" && modes == "+qo 000AAAAAB 000AAAAAB")), "founder opped: {out:?}"); e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "host.example".into() , ip: "0.0.0.0".into() }); assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into(), op: false }).is_empty(), "non-founder not opped");