chanserv: SOP protect tier over AOP (+ao), founder owner (+qo); resolve op access by capability not mode string
All checks were successful
CI / check (push) Successful in 3m59s

This commit is contained in:
Jean Chevronnet 2026-07-17 15:43:16 +00:00
parent c8106035df
commit 2f6fd41465
No known key found for this signature in database
7 changed files with 139 additions and 37 deletions

View file

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

View file

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

View file

@ -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 => {

View file

@ -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");