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
All checks were successful
CI / check (push) Successful in 3m59s
This commit is contained in:
parent
c8106035df
commit
2f6fd41465
7 changed files with 139 additions and 37 deletions
|
|
@ -632,7 +632,7 @@ pub const ACCESS_FLAGS: &str = "foOhvtiasg";
|
||||||
// The capabilities an access `level` confers (founder is layered on by callers).
|
// The capabilities an access `level` confers (founder is layered on by callers).
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct Caps {
|
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 op: bool, // moderation: kick / ban / op / mode / akick
|
||||||
pub topic: bool,
|
pub topic: bool,
|
||||||
pub invite: bool,
|
pub invite: bool,
|
||||||
|
|
@ -647,10 +647,12 @@ pub struct Caps {
|
||||||
// entries and new flag entries coexist.
|
// entries and new flag entries coexist.
|
||||||
pub fn level_caps(level: &str) -> Caps {
|
pub fn level_caps(level: &str) -> Caps {
|
||||||
match level {
|
match level {
|
||||||
"founder" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, set: true, greet: true, rank: 3 },
|
"founder" => Caps { auto: Some("+qo"), 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.
|
// The XOP tiers, ordered high to low. Each op-and-above tier carries op
|
||||||
// These are the tiers the XOP shortcuts store; ordered high to low.
|
// (+o, the @ prefix) plus its rank marker: founder owner (+q, ~), sop
|
||||||
"sop" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, rank: 2, ..Caps::default() },
|
// 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() },
|
"op" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, rank: 2, ..Caps::default() },
|
||||||
"halfop" => Caps { auto: Some("+h"), rank: 1, ..Caps::default() },
|
"halfop" => Caps { auto: Some("+h"), rank: 1, ..Caps::default() },
|
||||||
"voice" => Caps { auto: Some("+v"), 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
|
// The granular group-membership flags (GroupServ), same primitive, different
|
||||||
// letters: F founder, f modify the member/flags list, i invite members, s set
|
// 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.
|
// 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
|
// 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.
|
// the union of privileges, the higher rank, and the strongest auto-mode.
|
||||||
pub fn union(self, other: Caps) -> Caps {
|
pub fn union(self, other: Caps) -> Caps {
|
||||||
let rank_of = |m: Option<&'static str>| match m {
|
// Strength of an auto status mode, owner > protect > op > halfop > voice,
|
||||||
Some("+o") => 3,
|
// robust to letter order and to combined modes like "+qo" / "+ao".
|
||||||
Some("+h") => 2,
|
let rank_of = |m: Option<&'static str>| {
|
||||||
Some("+v") => 1,
|
m.map_or(0, |s| {
|
||||||
_ => 0,
|
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 };
|
let auto = if rank_of(self.auto) >= rank_of(other.auto) { self.auto } else { other.auto };
|
||||||
Caps {
|
Caps {
|
||||||
|
|
@ -1660,11 +1689,27 @@ mod tests {
|
||||||
assert!(t.topic && !t.op && t.auto.is_none());
|
assert!(t.topic && !t.op && t.auto.is_none());
|
||||||
assert!(level_caps("a").access && level_caps("f").set);
|
assert!(level_caps("a").access && level_caps("f").set);
|
||||||
|
|
||||||
// The four XOP tiers are distinct: SOP is AOP plus access-list rights,
|
// Each op-and-above tier carries op (+o) plus its rank marker, so all show
|
||||||
// HOP is its own halfop tier.
|
// @: founder +qo (~@), SOP +ao (&@), AOP +o (@), HOP +h.
|
||||||
assert!(level_caps("sop").op && level_caps("sop").access);
|
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!(level_caps("op").op && !level_caps("op").access);
|
||||||
assert_eq!(level_caps("halfop").auto, Some("+h"));
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use echo_api::Store;
|
use echo_api::Store;
|
||||||
use echo_api::{Sender, ServiceCtx};
|
use echo_api::{status_mode, Sender, ServiceCtx};
|
||||||
use echo_api::NetView;
|
use echo_api::NetView;
|
||||||
|
|
||||||
// ENFORCE <#channel>: re-apply the channel's settings to everyone present —
|
// 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<String> = net.channel_members(chan);
|
let members: Vec<String> = net.channel_members(chan);
|
||||||
for uid in members {
|
for uid in members {
|
||||||
match net.account_of(&uid).and_then(|a| info.join_mode(a)) {
|
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 => {
|
None => {
|
||||||
let nick = net.nick_of(&uid).unwrap_or("*");
|
let nick = net.nick_of(&uid).unwrap_or("*");
|
||||||
let host = net.host_of(&uid).unwrap_or("*");
|
let host = net.host_of(&uid).unwrap_or("*");
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// UP <#channel>: (re)apply the status mode your access entitles you to.
|
||||||
// DOWN <#channel>: drop your channel status modes. `up` selects which.
|
// 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 {
|
if up {
|
||||||
match info.join_mode(account) {
|
match info.join_mode(account) {
|
||||||
Some(mode) => {
|
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."));
|
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.")),
|
None => ctx.notice(me, from.uid, format!("You have no status access in \x02{chan}\x02.")),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Strip op and voice; the ircd ignores any mode you don't currently hold.
|
// Strip owner, admin, op, halfop and voice; the ircd ignores any you don't hold.
|
||||||
ctx.channel_mode(me, chan, &format!("-ov {} {}", from.uid, from.uid));
|
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."));
|
ctx.notice(me, from.uid, format!("Your status in \x02{chan}\x02 has been removed."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -601,11 +601,11 @@ impl KickerSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelInfo {
|
impl ChannelInfo {
|
||||||
/// The status mode to give `account` on join, if any: +o for the founder and
|
/// The status mode to give `account` on join, if any: +qo for the founder,
|
||||||
/// access-list ops, +v for voices.
|
/// +ao/+o for access-list ops, +v for voices.
|
||||||
pub fn join_mode(&self, account: &str) -> Option<&'static str> {
|
pub fn join_mode(&self, account: &str) -> Option<&'static str> {
|
||||||
if self.founder.eq_ignore_ascii_case(account) {
|
if self.founder.eq_ignore_ascii_case(account) {
|
||||||
return Some("+o");
|
return echo_api::level_caps("founder").auto;
|
||||||
}
|
}
|
||||||
self.access
|
self.access
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -613,6 +613,18 @@ impl ChannelInfo {
|
||||||
.and_then(|a| echo_api::level_caps(&a.level).auto)
|
.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.
|
/// The matching auto-kick entry for `hostmask` (nick!user@host), if any.
|
||||||
pub fn akick_match(&self, hostmask: &str) -> Option<&ChanAkick> {
|
pub fn akick_match(&self, hostmask: &str) -> Option<&ChanAkick> {
|
||||||
self.akick.iter().find(|k| glob_match(&k.mask, hostmask))
|
self.akick.iter().find(|k| glob_match(&k.mask, hostmask))
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,7 @@
|
||||||
db.access_add("#c", "bob", "voice").unwrap();
|
db.access_add("#c", "bob", "voice").unwrap();
|
||||||
|
|
||||||
let info = db.channel("#c").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("ALICE"), Some("+o")); // op, case-insensitive
|
||||||
assert_eq!(info.join_mode("bob"), Some("+v")); // voice
|
assert_eq!(info.join_mode("bob"), Some("+v")); // voice
|
||||||
assert_eq!(info.join_mode("nobody"), None);
|
assert_eq!(info.join_mode("nobody"), None);
|
||||||
|
|
|
||||||
|
|
@ -1176,6 +1176,9 @@ impl Engine {
|
||||||
// Group-aware: a member of a group that holds channel access gets
|
// Group-aware: a member of a group that holds channel access gets
|
||||||
// the group's status mode too.
|
// the group's status mode too.
|
||||||
let mode = account.as_deref().and_then(|a| self.db.channel_join_mode(&channel, a));
|
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());
|
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.
|
// Computed before the match below moves `channel` into its actions.
|
||||||
let greet = self.greet_on_join(&channel, account.as_deref());
|
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);
|
let is_bot = self.bot_uids.values().any(|b| b == &uid);
|
||||||
// SECUREOPS: a user who arrives opped (FJOIN prefix) but lacks op-level
|
// 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.
|
// 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}") });
|
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.
|
// 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 });
|
out.push(NetAction::Notice { from: from.clone(), to: uid.clone(), text: msg });
|
||||||
}
|
}
|
||||||
if autoop {
|
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.
|
// 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.
|
// SECUREOPS: a user who gains +o without op-level access loses it.
|
||||||
if op {
|
if op {
|
||||||
if let Some(c) = self.db.channel(&channel) {
|
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();
|
let from = self.chan_service.clone().unwrap_or_default();
|
||||||
return vec![NetAction::ChannelMode { from, channel, modes: format!("-o {uid}") }];
|
return vec![NetAction::ChannelMode { from, channel, modes: format!("-o {uid}") }];
|
||||||
}
|
}
|
||||||
|
|
@ -1278,7 +1281,7 @@ impl Engine {
|
||||||
NetEvent::TopicChange { channel, setter, topic } => {
|
NetEvent::TopicChange { channel, setter, topic } => {
|
||||||
let info = self.db.channel(&channel).map(|c| (c.settings.keeptopic, c.settings.topiclock, c.topic.clone()));
|
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.
|
// 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 {
|
match info {
|
||||||
// TOPICLOCK + unauthorised: put the kept topic back.
|
// TOPICLOCK + unauthorised: put the kept topic back.
|
||||||
Some((_, true, stored)) if !authorized => {
|
Some((_, true, stored)) if !authorized => {
|
||||||
|
|
|
||||||
|
|
@ -553,7 +553,7 @@
|
||||||
// Founder alice joins: the auto-op is sourced from the bot, not ChanServ.
|
// 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 join = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false });
|
||||||
let src = join.iter().find_map(|a| match a {
|
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,
|
_ => None,
|
||||||
}).expect("alice auto-opped");
|
}).expect("alice auto-opped");
|
||||||
assert_eq!(src, botuid, "auto-op fronts as the assigned bot");
|
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 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");
|
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");
|
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");
|
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:?}");
|
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
|
// 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.
|
// access) must NOT block that — you can always drop your own status.
|
||||||
#[test]
|
#[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::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() });
|
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.
|
// 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");
|
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::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() });
|
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 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)));
|
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).
|
// 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::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:?}");
|
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 });
|
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.
|
// 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 ADD carol"), "Added"));
|
||||||
assert!(notice(&to_cs(&mut e, "000AAAAAB", "AOP #c LIST"), "carol"));
|
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 });
|
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false });
|
||||||
to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@h2 out");
|
to_cs(&mut e, "000AAAAAB", "AKICK #c ADD *!*@h2 out");
|
||||||
let out = to_cs(&mut e, "000AAAAAB", "ENFORCE #c");
|
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:?}");
|
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.
|
// SYNC is an alias for ENFORCE — same re-apply of access modes.
|
||||||
let out = to_cs(&mut e, "000AAAAAB", "SYNC #c");
|
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.
|
// GETKEY: reflects a tracked key change.
|
||||||
e.handle(NetEvent::ChannelKey { channel: "#c".into(), key: Some("s3cret".into()) });
|
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() });
|
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 });
|
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() });
|
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");
|
assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into(), op: false }).is_empty(), "non-founder not opped");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue