chanserv: SOP protect (+ao) and founder owner (+qo) with standard ~ & @ prefix tiers
All checks were successful
CI / check (push) Successful in 4m12s
All checks were successful
CI / check (push) Successful in 4m12s
This commit is contained in:
parent
b5aef650f2
commit
e2330c56ff
3 changed files with 31 additions and 28 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,12 +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 },
|
||||||
// The XOP tiers, ordered high to low. Every op-and-above tier takes the op
|
// The XOP tiers, ordered high to low. Each op-and-above tier carries op
|
||||||
// mode (+o), so the whole network keeps a single @ prefix — the tiers are
|
// (+o, the @ prefix) plus its rank marker: founder owner (+q, ~), sop
|
||||||
// told apart by capability, not a channel symbol: SOP adds access-list
|
// protect (+a, &). So AOP and above all show @, while sop outranks aop and
|
||||||
// management (the `access` cap) over AOP, founder adds `set`.
|
// founder outranks sop. SOP additionally holds access-list management.
|
||||||
"sop" => Caps { auto: Some("+o"), op: true, topic: true, invite: true, access: true, rank: 2, ..Caps::default() },
|
"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() },
|
||||||
|
|
@ -1689,23 +1689,26 @@ 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);
|
||||||
|
|
||||||
// Every op-and-above tier resolves to +o (a single @ prefix). The tiers are
|
// Each op-and-above tier carries op (+o) plus its rank marker, so all show
|
||||||
// distinguished by capability, not the mode: SOP has `access`, AOP doesn't.
|
// @: 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("+o"));
|
assert_eq!(level_caps("founder").auto, Some("+qo"));
|
||||||
assert_eq!(level_caps("sop").auto, Some("+o"));
|
assert_eq!(level_caps("sop").auto, Some("+ao"), "SOP is op + protect");
|
||||||
assert_eq!(level_caps("op").auto, Some("+o"));
|
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: op (+o) over a voice group (+v).
|
// Combined, the stronger auto mode wins: SOP (+ao) over a voice group (+v),
|
||||||
assert_eq!(level_caps("sop").union(level_caps("voice")).auto, Some("+o"));
|
// 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]
|
#[test]
|
||||||
fn status_mode_repeats_the_target_per_letter() {
|
fn status_mode_repeats_the_target_per_letter() {
|
||||||
assert_eq!(status_mode("+o", "X"), "+o X");
|
assert_eq!(status_mode("+o", "X"), "+o X");
|
||||||
// Multi-letter modes (e.g. the DOWN strip) repeat the target per letter.
|
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");
|
assert_eq!(status_mode("-qaohv", "X"), "-qaohv X X X X X");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -1336,15 +1336,15 @@
|
||||||
|
|
||||||
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("-qaohv"))), "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 is opped (+o, the @ prefix) on join like any op tier, and secureops
|
// A SOP auto-gets op + protect (+ao, so it shows @ and outranks AOP) on join,
|
||||||
// never strips it — op access is a capability, not the literal "+o".
|
// and secureops never strips it — op access is a capability, not literal "+o".
|
||||||
#[test]
|
#[test]
|
||||||
fn sop_join_gets_protect_and_op_and_survives_secureops() {
|
fn sop_join_gets_protect_and_op_and_survives_secureops() {
|
||||||
use echo_chanserv::ChanServ;
|
use echo_chanserv::ChanServ;
|
||||||
|
|
@ -1371,11 +1371,11 @@
|
||||||
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: "SOP #c ADD mik".into() });
|
||||||
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAB".into(), text: "SET #c SECUREOPS ON".into() });
|
e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAB".into(), text: "SET #c SECUREOPS ON".into() });
|
||||||
|
|
||||||
// On join mik (a SOP) is opped like any op tier.
|
// 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 });
|
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false });
|
||||||
assert!(
|
assert!(
|
||||||
out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")),
|
out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+ao 000AAAAAB 000AAAAAB")),
|
||||||
"SOP is opped: {out:?}"
|
"SOP auto-gets op + protect: {out:?}"
|
||||||
);
|
);
|
||||||
// Arriving already opped, secureops must NOT strip a SOP's op.
|
// 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 });
|
let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: true });
|
||||||
|
|
@ -2016,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");
|
||||||
|
|
@ -2048,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).
|
||||||
|
|
@ -4883,7 +4883,7 @@
|
||||||
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.contains('o'))), "{out:?}");
|
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.contains('o'))), "{out:?}");
|
||||||
}
|
}
|
||||||
|
|
@ -4968,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 (founder) 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()) });
|
||||||
|
|
@ -5049,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