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

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