opertypes: oper classes + types — reusable capability classes and named roles (WHOIS title, auto usermodes/snomasks/vhost + level on oper-up, per-type command enforcement via on_pre_command); ships 5 built-in (helpop/globop/admin/servadmin/netadmin); oper blocks gain type=<id>; a typeless oper keeps full access

This commit is contained in:
Jean Chevronnet 2026-08-20 10:05:11 +00:00
parent f3ce0e61ef
commit 01516d5fdc
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
6 changed files with 509 additions and 5 deletions

View file

@ -40,6 +40,7 @@ pub struct OperBlock {
pub password: String, pub password: String,
pub level: u32, pub level: u32,
pub fingerprint: Option<String>, pub fingerprint: Option<String>,
pub oper_type: Option<String>,
} }
/// A trusted WEBIRC gateway: after presenting `password` it may rewrite a client's /// A trusted WEBIRC gateway: after presenting `password` it may rewrite a client's
@ -233,14 +234,18 @@ impl Config {
password: p.to_string(), password: p.to_string(),
level: 0, level: 0,
fingerprint: None, fingerprint: None,
oper_type: None,
}; };
// trailing tokens (any order): a number is the operlevel, a // trailing tokens (any order): a number is the operlevel, a
// `fp=`/`certfp=` token is the required TLS cert fingerprint. // `fp=`/`certfp=` token is the required TLS cert fingerprint,
// a `type=` token names the oper type (see modules::opertypes).
for tok in it { for tok in it {
if let Some(fp) = if let Some(fp) =
tok.strip_prefix("fp=").or_else(|| tok.strip_prefix("certfp=")) tok.strip_prefix("fp=").or_else(|| tok.strip_prefix("certfp="))
{ {
b.fingerprint = Some(fp.to_ascii_lowercase()); b.fingerprint = Some(fp.to_ascii_lowercase());
} else if let Some(t) = tok.strip_prefix("type=") {
b.oper_type = Some(t.to_string());
} else if let Ok(l) = tok.parse::<u32>() { } else if let Ok(l) = tok.parse::<u32>() {
b.level = l; b.level = l;
} }

View file

@ -298,12 +298,20 @@ impl Command for Whois {
s.numeric(uid, RPL_WHOISCHANNELS, &format!("{nick} :{line}")); s.numeric(uid, RPL_WHOISCHANNELS, &format!("{nick} :{line}"));
} }
} }
// 313: is an IRC operator (hidden by +H unless the asker is an oper) // 313: is a/an <oper type> (the type's title, else plain "IRC operator");
// hidden by +H unless the asker is an oper.
if oper && (!hideoper || asker_oper) { if oper && (!hideoper || asker_oper) {
let title = crate::modules::opertypes::title_of(s, tuid)
.unwrap_or_else(|| "IRC operator".to_string());
let article = if title.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) {
"an"
} else {
"a"
};
s.numeric( s.numeric(
uid, uid,
RPL_WHOISOPERATOR, RPL_WHOISOPERATOR,
&format!("{nick} :is an IRC operator"), &format!("{nick} :is {article} {title}"),
); );
} }
// 320: oper-set SWHOIS line. No redundant target-nick param — just the // 320: oper-set SWHOIS line. No redundant target-nick param — just the

View file

@ -128,6 +128,7 @@ impl Command for Oper {
return CmdResult::Fail; return CmdResult::Fail;
}; };
let (hash, level) = (block.password.clone(), block.level); let (hash, level) = (block.password.clone(), block.level);
let otype = block.oper_type.clone();
// fingerprint login: the block demands a specific TLS client-cert SHA-256 // fingerprint login: the block demands a specific TLS client-cert SHA-256
// fingerprint, so the user must be on a matching certificate. // fingerprint, so the user must be on a matching certificate.
if let Some(want_fp) = &block.fingerprint { if let Some(want_fp) = &block.fingerprint {
@ -152,14 +153,16 @@ impl Command for Oper {
if hash == "*" { if hash == "*" {
s.oper_up(uid); s.oper_up(uid);
crate::modules::operlevels::set(s, uid, level); crate::modules::operlevels::set(s, uid, level);
crate::modules::opertypes::apply(s, uid, otype.as_deref());
return CmdResult::Ok; return CmdResult::Ok;
} }
// a KDF password (bcrypt / pbkdf2) is slow — verify it off the core thread // a KDF password (bcrypt / pbkdf2) is slow — verify it off the core thread
// (result arrives as OperAuth), so it can't freeze the server or be a DoS. // (result arrives as OperAuth), so it can't freeze the server or be a DoS.
if crate::modules::password_hash::is_slow(&hash) { if crate::modules::password_hash::is_slow(&hash) {
let ot = otype.clone();
let ok = s.spawn_crypto(move || { let ok = s.spawn_crypto(move || {
let ok = crate::modules::password_hash::verify(&hash, &pass); let ok = crate::modules::password_hash::verify(&hash, &pass);
crate::ircd::Event::OperAuth { uid, ok, level } crate::ircd::Event::OperAuth { uid, ok, level, oper_type: ot }
}); });
if !ok { if !ok {
s.numeric(uid, ERR_PASSWDMISMATCH, ":Too many auth attempts, try again"); s.numeric(uid, ERR_PASSWDMISMATCH, ":Too many auth attempts, try again");
@ -171,6 +174,7 @@ impl Command for Oper {
if crate::modules::password_hash::verify(&hash, &pass) { if crate::modules::password_hash::verify(&hash, &pass) {
s.oper_up(uid); s.oper_up(uid);
crate::modules::operlevels::set(s, uid, level); // operlevels: KILL protection crate::modules::operlevels::set(s, uid, level); // operlevels: KILL protection
crate::modules::opertypes::apply(s, uid, otype.as_deref());
CmdResult::Ok CmdResult::Ok
} else { } else {
s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect"); s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");

View file

@ -59,6 +59,7 @@ pub enum Event {
uid: Uid, uid: Uid,
ok: bool, ok: bool,
level: u32, level: u32,
oper_type: Option<String>,
}, },
/// A background MKPASSWD hash finished (KDFs run off the core thread). /// A background MKPASSWD hash finished (KDFs run off the core thread).
MkpasswdResult { MkpasswdResult {
@ -235,10 +236,11 @@ impl Ircd {
crate::modules::ident::on_result(&mut self.server, uid, ident); crate::modules::ident::on_result(&mut self.server, uid, ident);
self.try_register(uid); // ident may have been the last hold self.try_register(uid); // ident may have been the last hold
} }
Event::OperAuth { uid, ok, level } => { Event::OperAuth { uid, ok, level, oper_type } => {
if ok { if ok {
self.server.oper_up(uid); self.server.oper_up(uid);
crate::modules::operlevels::set(&mut self.server, uid, level); crate::modules::operlevels::set(&mut self.server, uid, level);
crate::modules::opertypes::apply(&mut self.server, uid, oper_type.as_deref());
} else if self.server.users.contains_key(&uid) { } else if self.server.users.contains_key(&uid) {
self.server self.server
.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect"); .numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");

View file

@ -54,6 +54,7 @@ pub mod network_icon;
pub mod ojoin; pub mod ojoin;
pub mod operlevels; pub mod operlevels;
pub mod operprefix; pub mod operprefix;
pub mod opertypes;
pub mod password_hash; pub mod password_hash;
pub mod permchannels; pub mod permchannels;
pub mod profilelink; pub mod profilelink;
@ -112,6 +113,7 @@ pub fn default_modules() -> Vec<Box<dyn Module>> {
Box::new(autoop::AutoOp), Box::new(autoop::AutoOp),
Box::new(autodrop::AutoDrop), Box::new(autodrop::AutoDrop),
Box::new(operprefix::OperPrefix), Box::new(operprefix::OperPrefix),
Box::new(opertypes::OperTypes),
Box::new(permchannels::PermChannels::default()), Box::new(permchannels::PermChannels::default()),
Box::new(chathistory::ChatHistoryGc), Box::new(chathistory::ChatHistoryGc),
Box::new(account_registration::AcctRegGc), Box::new(account_registration::AcctRegGc),

483
src/modules/opertypes.rs Normal file
View file

@ -0,0 +1,483 @@
//! opertypes — oper classes + types. A `class` is a reusable
//! capability bundle (commands / privs / snomasks); an `opertype` is a named role
//! (the WHOIS title) built from classes plus auto usermodes / snomasks / vhost. An
//! `oper` block selects one with `type=<id>`. Enforced through `on_pre_command`; an
//! oper with no type keeps full access (legacy). Five types ship built-in.
//!
//! Config (repeatable):
//! class = <id> commands=A,B privs=x,y snomasks=abc
//! opertype = <id> classes=c1,c2 [commands=..] [privs=..] modes=+iw snomasks=+cg \
//! [vhost=host.name] [title=Nice_Title] [level=N]
//! oper = <name> <pass> type=<id> [fp=..]
use std::cell::RefCell;
use std::collections::HashSet;
use crate::map::HashMap;
use crate::module::{ModResult, Module};
use crate::server::Server;
use crate::users::DEFAULT_SNOMASK;
use crate::Uid;
/// Per-user resolved grant, stored on `User.ext` at oper-up. Present ⇒ a typed
/// oper; absent ⇒ a legacy oper with full access. Read by WHOIS for the title.
pub struct OperType {
pub title: String,
pub all_commands: bool,
pub commands: HashSet<String>,
pub all_privs: bool,
pub privs: HashSet<String>,
}
/// Commands an oper type gates. Anything outside this set (OPERMOTD, MKPASSWD,
/// ALLTIME, WHOIS, …) is open to every oper.
const GATED: &[&str] = &[
"KILL", "KLINE", "GLINE", "ZLINE", "QLINE", "ELINE", "RLINE", "SHUN", "CBAN",
"CHECK", "NICKLOCK", "NICKUNLOCK", "SAJOIN", "SAPART", "SANICK", "SAKICK",
"SAMODE", "SATOPIC", "SAQUIT", "CLEARCHAN", "SVSNICK", "SVSJOIN", "SVSPART",
"SVSMODE", "SVSLOGIN", "SVSLOGOUT", "CHGHOST", "CHGIDENT", "CHGNAME", "SETHOST",
"SETIDENT", "SETIDLE", "SWHOIS", "WALLOPS", "GLOBOPS", "CONNECT", "SQUIT",
"DIE", "RESTART",
];
fn gated(cmd: &str) -> bool {
GATED.contains(&cmd.to_ascii_uppercase().as_str())
}
pub struct OperTypes;
impl Module for OperTypes {
fn name(&self) -> &'static str {
"opertypes"
}
fn on_pre_command(&mut self, srv: &mut Server, uid: Uid, cmd: &str, _params: &[String]) -> ModResult {
if !srv.is_oper(uid) || !gated(cmd) {
return ModResult::Passthru;
}
let up = cmd.to_ascii_uppercase();
let (allowed, title) = match srv.users.get(&uid).and_then(|u| u.ext.get::<OperType>()) {
None => (true, String::new()), // legacy full-access oper (no type)
Some(t) => (t.all_commands || t.commands.contains(&up), t.title.clone()),
};
if allowed {
return ModResult::Passthru;
}
srv.numeric(
uid,
crate::numeric::ERR_NOPRIVILEGES,
&format!(":Permission denied — your \x02{title}\x02 oper type may not use {up}"),
);
ModResult::Deny
}
}
/// The WHOIS title of a typed oper, if any (read by core_info's 313).
pub fn title_of(s: &Server, uid: Uid) -> Option<String> {
s.users.get(&uid).and_then(|u| u.ext.get::<OperType>()).map(|t| t.title.clone())
}
/// Apply the oper's type at oper-up: auto usermodes / snomasks / vhost / level, then
/// store the grant + title. A missing type (or an unknown id) leaves the oper with
/// full access, so `oper` blocks without `type=` keep working.
pub fn apply(s: &mut Server, uid: Uid, type_id: Option<&str>) {
let Some(id) = type_id.map(|t| t.to_ascii_lowercase()) else {
return;
};
let Some(r) = with_resolved(s, |m| m.get(&id).cloned()) else {
s.snotice_c('o', &format!("oper type '{id}' is not defined — granting full access"));
return;
};
if !r.modes.is_empty() {
crate::coremods::core_mode::svs_set_user_modes(s, uid, &r.modes);
}
if r.all_snomasks {
set_snomask(s, uid, DEFAULT_SNOMASK);
} else if let Some(letters) = &r.snomasks {
set_snomask(s, uid, letters);
}
if let Some(h) = &r.vhost {
s.change_host_ident(uid, None, Some(h));
}
if let Some(lvl) = r.level {
crate::modules::operlevels::set(s, uid, lvl);
}
if let Some(u) = s.users.get_mut(&uid) {
u.ext.set(OperType {
title: r.title.clone(),
all_commands: r.all_commands,
commands: r.commands.clone(),
all_privs: r.all_privs,
privs: r.privs.clone(),
});
}
}
fn set_snomask(s: &mut Server, uid: Uid, letters: &str) {
let cats: String = letters.chars().filter(|c| DEFAULT_SNOMASK.contains(*c)).collect();
if let Some(u) = s.users.get_mut(&uid) {
u.flags.snomask = !cats.is_empty();
u.flags.snomask_cats = cats;
}
}
// --- resolved types (built-in + config), rebuilt on rehash -------------------
#[derive(Clone)]
struct Resolved {
title: String,
all_commands: bool,
commands: HashSet<String>,
all_privs: bool,
privs: HashSet<String>,
modes: String,
snomasks: Option<String>, // Some(letters) auto-set; None = keep the oper-up default
all_snomasks: bool,
vhost: Option<String>,
level: Option<u32>,
}
thread_local! {
static TYPES: RefCell<(u64, HashMap<String, Resolved>)> =
RefCell::new((u64::MAX, HashMap::default()));
}
fn with_resolved<R>(s: &Server, f: impl FnOnce(&HashMap<String, Resolved>) -> R) -> R {
TYPES.with(|cell| {
if cell.borrow().0 != s.config_gen {
let fresh = build_types(s);
*cell.borrow_mut() = (s.config_gen, fresh);
}
f(&cell.borrow().1)
})
}
#[derive(Default, Clone)]
struct ClassDef {
all_commands: bool,
commands: Vec<String>,
all_privs: bool,
privs: Vec<String>,
all_snomasks: bool,
snomasks: String,
}
#[derive(Default, Clone)]
struct TypeDef {
title: String,
all_classes: bool,
classes: Vec<String>,
all_commands: bool,
commands: Vec<String>,
all_privs: bool,
privs: Vec<String>,
modes: String,
all_snomasks: bool,
snomasks: String,
vhost: Option<String>,
level: Option<u32>,
}
fn cdef(commands: &[&str], privs: &[&str], sno: &str) -> ClassDef {
ClassDef {
commands: commands.iter().map(|c| c.to_string()).collect(),
privs: privs.iter().map(|p| p.to_string()).collect(),
snomasks: sno.to_string(),
..Default::default()
}
}
fn tdef(title: &str, classes: &[&str], all_classes: bool, modes: &str, sno: &str, all_sno: bool, level: u32) -> TypeDef {
TypeDef {
title: title.to_string(),
all_classes,
classes: classes.iter().map(|s| s.to_string()).collect(),
modes: modes.to_string(),
snomasks: sno.to_string(),
all_snomasks: all_sno,
level: Some(level),
..Default::default()
}
}
/// The five ships-with-echoIRCd classes + types.
fn builtin() -> (HashMap<String, ClassDef>, HashMap<String, TypeDef>) {
let mut classes: HashMap<String, ClassDef> = HashMap::default();
classes.insert("announce".into(), cdef(&["WALLOPS", "GLOBOPS"], &[], "ag"));
classes.insert("ban".into(), cdef(&["KILL", "KLINE", "GLINE", "ZLINE", "QLINE", "ELINE", "RLINE", "SHUN", "CBAN", "CHECK", "NICKLOCK", "NICKUNLOCK"], &[], "kx"));
classes.insert("override".into(), cdef(&["SAJOIN", "SAPART", "SANICK", "SAKICK", "SAMODE", "SATOPIC", "SAQUIT", "CLEARCHAN"], &["override"], "v"));
classes.insert("host".into(), cdef(&["CHGHOST", "CHGIDENT", "CHGNAME", "SETHOST", "SETIDENT", "SETIDLE", "SWHOIS"], &[], ""));
classes.insert("services".into(), cdef(&["SVSNICK", "SVSJOIN", "SVSPART", "SVSMODE", "SVSLOGIN", "SVSLOGOUT"], &[], ""));
classes.insert("server".into(), cdef(&["CONNECT", "SQUIT", "DIE", "RESTART"], &[], "lr"));
let mut types: HashMap<String, TypeDef> = HashMap::default();
// title classes all modes sno all* level
types.insert("helpop".into(), tdef("Help Operator", &[], false, "+ih", "o", false, 10));
types.insert("globop".into(), tdef("GlobOp", &["announce"], false, "+iw", "acgoq", false, 20));
types.insert("admin".into(), tdef("Administrator", &["announce", "ban", "override", "host"], false, "+iw", "", true, 50));
types.insert("servadmin".into(), tdef("Services Administrator", &["announce", "ban", "override", "host", "services"], false, "+iw", "", true, 70));
types.insert("netadmin".into(), tdef("Network Administrator", &[], true, "+iw", "", true, 100));
(classes, types)
}
fn build_types(s: &Server) -> HashMap<String, Resolved> {
let (mut classes, mut types) = builtin();
for line in s.conf_all("class") {
let mut it = line.split_whitespace();
let Some(id) = it.next() else { continue };
let mut cd = classes.get(id).cloned().unwrap_or_default();
for tok in it {
if let Some((k, v)) = tok.split_once('=') {
apply_class_kv(&mut cd, k, v);
}
}
classes.insert(id.to_string(), cd);
}
for line in s.conf_all("opertype") {
let mut it = line.split_whitespace();
let Some(id) = it.next() else { continue };
let mut td = types.get(id).cloned().unwrap_or_default();
for tok in it {
if let Some((k, v)) = tok.split_once('=') {
apply_type_kv(&mut td, k, v);
}
}
if td.title.is_empty() {
td.title = id.to_string();
}
types.insert(id.to_string(), td);
}
types.iter().map(|(id, td)| (id.clone(), resolve(td, &classes))).collect()
}
fn apply_class_kv(cd: &mut ClassDef, k: &str, v: &str) {
match k {
"commands" | "cmds" => {
if v == "*" {
cd.all_commands = true;
} else {
cd.commands.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_ascii_uppercase()));
}
}
"privs" => {
if v == "*" {
cd.all_privs = true;
} else {
cd.privs.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_ascii_lowercase()));
}
}
"snomasks" | "snomask" => {
if v.contains('*') {
cd.all_snomasks = true;
} else {
cd.snomasks.push_str(v);
}
}
_ => {} // usermodes/chanmodes allowlist: accepted but not yet enforced
}
}
fn apply_type_kv(td: &mut TypeDef, k: &str, v: &str) {
match k {
"classes" => {
if v == "*" {
td.all_classes = true;
} else {
td.classes.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_string()));
}
}
"commands" | "cmds" => {
if v == "*" {
td.all_commands = true;
} else {
td.commands.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_ascii_uppercase()));
}
}
"privs" => {
if v == "*" {
td.all_privs = true;
} else {
td.privs.extend(v.split(',').filter(|x| !x.is_empty()).map(|x| x.to_ascii_lowercase()));
}
}
"modes" | "usermodes" => td.modes = v.to_string(),
"snomasks" | "snomask" => {
if v.contains('*') {
td.all_snomasks = true;
} else {
td.snomasks.push_str(v);
}
}
"vhost" | "host" => td.vhost = Some(v.to_string()),
"title" => td.title = v.replace('_', " "),
"level" => {
if let Ok(l) = v.parse() {
td.level = Some(l);
}
}
_ => {} // maxchans etc.: accepted, not yet enforced
}
}
fn resolve(td: &TypeDef, classes: &HashMap<String, ClassDef>) -> Resolved {
let mut all_commands = td.all_commands || td.all_classes;
let mut commands: HashSet<String> = td.commands.iter().cloned().collect();
let mut all_privs = td.all_privs || td.all_classes;
let mut privs: HashSet<String> = td.privs.iter().cloned().collect();
let mut all_sno = td.all_snomasks;
let mut sno = td.snomasks.clone();
let names: Vec<String> = if td.all_classes {
classes.keys().cloned().collect()
} else {
td.classes.clone()
};
for name in names {
if let Some(cd) = classes.get(&name) {
all_commands |= cd.all_commands;
commands.extend(cd.commands.iter().cloned());
all_privs |= cd.all_privs;
privs.extend(cd.privs.iter().cloned());
all_sno |= cd.all_snomasks;
sno.push_str(&cd.snomasks);
}
}
let snomasks = if all_sno {
None
} else {
let mut seen = String::new();
for ch in sno.chars() {
if DEFAULT_SNOMASK.contains(ch) && !seen.contains(ch) {
seen.push(ch);
}
}
(!seen.is_empty()).then_some(seen)
};
Resolved {
title: td.title.clone(),
all_commands,
commands,
all_privs,
privs,
modes: td.modes.clone(),
snomasks,
all_snomasks: all_sno,
vhost: td.vhost.clone(),
level: td.level,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_types_grant_the_right_commands() {
let (classes, types) = builtin();
let r = |id: &str| resolve(&types[id], &classes);
// Network Administrator: everything.
assert!(r("netadmin").all_commands);
// Administrator: bans + overrides, but not services or server control.
let admin = r("admin");
assert!(!admin.all_commands);
assert!(admin.commands.contains("KILL"));
assert!(admin.commands.contains("SAMODE"));
assert!(!admin.commands.contains("CONNECT"));
assert!(!admin.commands.contains("SVSNICK"));
// Services Administrator: Administrator + services.
let sa = r("servadmin");
assert!(sa.commands.contains("KILL"));
assert!(sa.commands.contains("SVSNICK"));
assert!(!sa.commands.contains("CONNECT"));
// GlobOp: announce only.
let g = r("globop");
assert!(g.commands.contains("GLOBOPS"));
assert!(!g.commands.contains("KILL"));
// Help Operator: no gated commands.
assert!(r("helpop").commands.is_empty());
}
#[test]
fn gated_covers_privileged_commands_only() {
assert!(gated("kill") && gated("CONNECT") && gated("SamODE"));
assert!(!gated("WHOIS") && !gated("MKPASSWD") && !gated("PRIVMSG"));
}
#[test]
fn config_opertype_overrides_and_composes() {
let (mut classes, mut types) = builtin();
// a custom class + type layered on top, as config would add
classes.insert("readonly".into(), cdef(&["CHECK"], &[], "t"));
let mut td = TypeDef { title: "Watcher".into(), ..Default::default() };
apply_type_kv(&mut td, "classes", "readonly");
apply_type_kv(&mut td, "commands", "GLOBOPS");
types.insert("watcher".into(), td);
let r = resolve(&types["watcher"], &classes);
assert_eq!(r.title, "Watcher");
assert!(r.commands.contains("CHECK") && r.commands.contains("GLOBOPS"));
assert!(!r.commands.contains("KILL"));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn resolved(id: &str) -> Resolved {
let (classes, types) = builtin();
resolve(types.get(id).expect("builtin type"), &classes)
}
#[test]
fn builtin_types_grant_the_right_capabilities() {
let helpop = resolved("helpop");
assert_eq!(helpop.title, "Help Operator");
assert!(!helpop.all_commands);
assert!(helpop.commands.is_empty(), "helpop gets no gated commands");
assert_eq!(helpop.modes, "+ih");
let globop = resolved("globop");
assert!(globop.commands.contains("GLOBOPS") && globop.commands.contains("WALLOPS"));
assert!(!globop.commands.contains("KILL"), "globop can't KILL");
let admin = resolved("admin");
assert!(admin.commands.contains("KILL") && admin.commands.contains("SAJOIN") && admin.commands.contains("CHGHOST"));
assert!(admin.privs.contains("override"));
assert!(!admin.commands.contains("DIE"), "admin can't DIE");
assert!(!admin.commands.contains("SVSNICK"), "admin isn't a services admin");
assert!(admin.all_snomasks);
let servadmin = resolved("servadmin");
assert!(servadmin.commands.contains("SVSNICK") && servadmin.commands.contains("KILL"));
assert!(!servadmin.commands.contains("DIE"), "servadmin can't DIE");
let netadmin = resolved("netadmin");
assert_eq!(netadmin.title, "Network Administrator");
assert!(netadmin.all_commands, "netadmin gets everything");
}
#[test]
fn gated_covers_the_dangerous_commands_only() {
assert!(gated("kill") && gated("DIE") && gated("svsnick") && gated("CONNECT"));
assert!(!gated("whois") && !gated("opermotd") && !gated("mkpasswd"));
}
// A config `opertype`/`class` extends or overrides the built-ins by id.
#[test]
fn config_class_and_type_parse() {
let mut cd = ClassDef::default();
apply_class_kv(&mut cd, "commands", "kill,gline");
apply_class_kv(&mut cd, "snomasks", "kx");
assert!(cd.commands.contains(&"KILL".to_string()) && cd.commands.contains(&"GLINE".to_string()));
assert_eq!(cd.snomasks, "kx");
let mut td = TypeDef::default();
apply_type_kv(&mut td, "classes", "*");
apply_type_kv(&mut td, "title", "Big_Boss");
apply_type_kv(&mut td, "level", "99");
assert!(td.all_classes);
assert_eq!(td.title, "Big Boss");
assert_eq!(td.level, Some(99));
}
}