From bc4fada354ec54437efb66a6301137f80d9a0645 Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 18:47:08 +0000 Subject: [PATCH] api: type GroupServ flags (GroupFlag/GroupFlags via a shared flag_set! macro); delete the stringly apply_flags --- api/src/lib.rs | 185 +++++++++++++++++++-------------- modules/groupserv/src/flags.rs | 9 +- modules/groupserv/src/lib.rs | 8 +- src/engine/db/network.rs | 4 +- 4 files changed, 121 insertions(+), 85 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 6f2ad97..5c09c2a 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -660,6 +660,58 @@ pub struct Caps { pub rank: Rank, // ordered tier for PEACE comparisons (see `Rank`) } +/// Generate a Copy bitset newtype over a letter-mapped flag enum — one that +/// provides `ALL: [Self; N]`, `letter(self) -> char` and `from_letter`. The +/// single source of the letter-based flag-set mechanics shared by [`Flags`] +/// (channel access) and [`GroupFlags`] (group membership). +macro_rules! flag_set { + ($(#[$doc:meta])* $set:ident : $flag:ty = $int:ty) => { + $(#[$doc])* + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub struct $set($int); + + impl $set { + /// Add a flag (builder style). + pub fn with(self, f: $flag) -> Self { $set(self.0 | (1 << f as u8)) } + /// Remove a flag. + pub fn without(self, f: $flag) -> Self { $set(self.0 & !(1 << f as u8)) } + /// Whether this set holds `f`. + pub fn has(self, f: $flag) -> bool { self.0 & (1 << f as u8) != 0 } + /// No flags held. + pub fn is_empty(self) -> bool { self.0 == 0 } + + /// Parse a letter string, silently skipping unknown letters — lenient, + /// for reading stored state. Use [`Self::apply_delta`] for user input. + pub fn parse(s: &str) -> Self { + s.chars().filter_map(<$flag>::from_letter).fold(Self::default(), Self::with) + } + + /// Apply a `+ab-c`-style delta (bare letters grant). `Err(letter)` on the + /// first character that isn't `+`, `-`, or a known flag. + pub fn apply_delta(self, delta: &str) -> Result { + let mut set = self; + let mut adding = true; + for c in delta.chars() { + match c { + '+' => adding = true, + '-' => adding = false, + _ => match <$flag>::from_letter(c) { + Some(f) => set = if adding { set.with(f) } else { set.without(f) }, + None => return Err(c), + }, + } + } + Ok(set) + } + + /// The canonical letter string, in `ALL` order — the stored/displayed form. + pub fn to_letters(self) -> String { + <$flag>::ALL.into_iter().filter(|f| self.has(*f)).map(<$flag>::letter).collect() + } + } + }; +} + /// A single channel-access flag — typed rather than a bare char, so the compiler /// checks every use and there's one source of truth for the letter and what it /// grants. Stored (wire / access list) as its letter; the set is a [`Flags`]. @@ -705,32 +757,13 @@ impl Flag { } } -/// The set of access flags an entry holds — a Copy bitset, the same pattern as -/// [`Privs`]. The typed form of a stored flag string like "otia". -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct Flags(u16); +flag_set!( + /// The set of channel-access flags an entry holds — the typed form of a stored + /// flag string like "otia". Same bitset primitive as [`Privs`] / [`GroupFlags`]. + Flags: Flag = u16 +); impl Flags { - pub fn with(self, f: Flag) -> Self { - Flags(self.0 | (1 << f as u8)) - } - pub fn without(self, f: Flag) -> Self { - Flags(self.0 & !(1 << f as u8)) - } - pub fn has(self, f: Flag) -> bool { - self.0 & (1 << f as u8) != 0 - } - pub fn is_empty(self) -> bool { - self.0 == 0 - } - - /// Parse a flag-letter string (e.g. "otia"), silently skipping any letter that - /// isn't a flag — for reading a stored level. Use [`Flags::apply_delta`] for - /// user input, which reports a bad letter instead. - pub fn parse(s: &str) -> Flags { - s.chars().filter_map(Flag::from_letter).fold(Flags::default(), Flags::with) - } - /// The flags a stored `level` resolves to: the tier presets map to equivalent /// flags, anything else is parsed as a flag string. (The `founder` preset — /// the channel owner — is handled in `level_caps`, not here.) @@ -746,30 +779,6 @@ impl Flags { } } - /// Apply a `+ov-h`-style delta (bare letters grant). `Err(letter)` on the first - /// character that isn't `+`, `-`, or a known flag. - pub fn apply_delta(self, delta: &str) -> Result { - let mut set = self; - let mut adding = true; - for c in delta.chars() { - match c { - '+' => adding = true, - '-' => adding = false, - _ => match Flag::from_letter(c) { - Some(f) => set = if adding { set.with(f) } else { set.without(f) }, - None => return Err(c), - }, - } - } - Ok(set) - } - - /// The canonical flag-letter string, in `Flag::ALL` order — the form stored and - /// shown by FLAGS. - pub fn to_letters(self) -> String { - Flag::ALL.into_iter().filter(|f| self.has(*f)).map(Flag::letter).collect() - } - /// The capabilities these flags confer. pub fn caps(self) -> Caps { let founderish = self.has(Flag::Founder); @@ -898,10 +907,49 @@ pub fn status_mode(modes: &str, target: &str) -> String { } // 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. +// letters (see `GroupFlag`). pub const GROUP_FLAGS: &str = "Fficsm"; +/// A single group-membership flag (GroupServ) — the same primitive as [`Flag`] +/// with a different letter set. Stored as its letter; the set is a [`GroupFlags`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupFlag { + Founder, // F — founder-level management + Manage, // f — modify the member / flags list + Invite, // i — invite members + Channel, // c — inherit the group's channel access + Set, // s — set group options + Memo, // m — group memos +} + +impl GroupFlag { + /// Every flag, in canonical (display / GROUP_FLAGS) order. + pub const ALL: [GroupFlag; 6] = [GroupFlag::Founder, GroupFlag::Manage, GroupFlag::Invite, GroupFlag::Channel, GroupFlag::Set, GroupFlag::Memo]; + + /// The flag's letter. Exhaustive, so a new variant forces choosing its letter. + pub fn letter(self) -> char { + match self { + GroupFlag::Founder => 'F', + GroupFlag::Manage => 'f', + GroupFlag::Invite => 'i', + GroupFlag::Channel => 'c', + GroupFlag::Set => 's', + GroupFlag::Memo => 'm', + } + } + + /// Parse a flag letter; `None` if it isn't a recognised group flag. + pub fn from_letter(c: char) -> Option { + Self::ALL.into_iter().find(|f| f.letter() == c) + } +} + +flag_set!( + /// The set of group-membership flags a member holds — the typed form of a + /// stored flag string like "Fc". Same bitset primitive as [`Flags`] / [`Privs`]. + GroupFlags: GroupFlag = u8 +); + 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. @@ -953,28 +1001,6 @@ pub struct GroupView { pub members: Vec, } -// Apply a `+ov-h`-style delta to a flag string against a `valid` letter set, -// returning the new deduplicated flag string ordered by `valid`. A bare "ov" -// (no sign) grants. Returns Err with the first letter not in `valid`. -pub fn apply_flags(current: &str, delta: &str, valid: &str) -> Result { - let mut set: Vec = current.chars().filter(|c| valid.contains(*c)).collect(); - let mut adding = true; - for c in delta.chars() { - match c { - '+' => adding = true, - '-' => adding = false, - f if valid.contains(f) => { - set.retain(|&x| x != f); - if adding { - set.push(f); - } - } - other => return Err(other), - } - } - Ok(valid.chars().filter(|c| set.contains(c)).collect()) -} - // One auto-kick entry (a hostmask and the reason shown on kick). #[derive(Debug, Clone)] pub struct ChanAkickView { @@ -1817,17 +1843,18 @@ mod tests { assert_eq!(Flags::from_level("sop").caps().rank, Rank::Admin); assert_eq!(Flags::from_level("halfop").to_letters(), "h"); assert_eq!(Flags::from_level("oa").caps().auto, Some("+ao"), "op+access = protected admin"); + + // Group flags are the same primitive (the macro-generated bitset); GROUP_FLAGS + // stays in sync with the enum and deltas apply the same way. + assert_eq!(GROUP_FLAGS, GroupFlag::ALL.iter().map(|f| f.letter()).collect::()); + assert_eq!(GroupFlags::parse("cF").to_letters(), "Fc", "canonical order"); + assert!(GroupFlags::parse("Fc").has(GroupFlag::Channel)); + assert_eq!(GroupFlags::default().apply_delta("+cm").unwrap().to_letters(), "cm"); + assert_eq!(GroupFlags::default().apply_delta("+cz"), Err('z')); } #[test] fn access_flags_apply_and_resolve() { - // +/- deltas, dedup, stable order, invalid flag rejected. - assert_eq!(apply_flags("", "+ov", ACCESS_FLAGS).unwrap(), "ov"); - assert_eq!(apply_flags("ov", "-o", ACCESS_FLAGS).unwrap(), "v"); - assert_eq!(apply_flags("v", "+tv", ACCESS_FLAGS).unwrap(), "vt"); // ACCESS_FLAGS order, no dup - assert_eq!(apply_flags("", "ai", ACCESS_FLAGS).unwrap(), "ia"); // bare letters grant - assert_eq!(apply_flags("", "+ox", ACCESS_FLAGS), Err('x')); - // Legacy presets and flag strings both resolve to capabilities. assert_eq!(level_caps("op").auto, Some("+o")); assert!(level_caps("op").op && level_caps("op").topic); diff --git a/modules/groupserv/src/flags.rs b/modules/groupserv/src/flags.rs index f11e64a..6a4c056 100644 --- a/modules/groupserv/src/flags.rs +++ b/modules/groupserv/src/flags.rs @@ -1,4 +1,4 @@ -use echo_api::{apply_flags, Sender, ServiceCtx, Store, GROUP_FLAGS}; +use echo_api::{GroupFlags, Sender, ServiceCtx, Store, GROUP_FLAGS}; // FLAGS [account [+/-flags]]: list, show, or change group-access flags. // Listing/showing is open; changing needs the founder or the `f` flag. @@ -39,15 +39,16 @@ pub fn handle(me: &str, from: &Sender, name: Option<&str>, target: Option<&str>, ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered.")); return; }; - let updated = match apply_flags(current.as_deref().unwrap_or(""), delta, GROUP_FLAGS) { + let updated = match GroupFlags::parse(current.as_deref().unwrap_or("")).apply_delta(delta) { Ok(f) => f, Err(bad) => { ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid group flag. Valid: \x02{GROUP_FLAGS}\x02.")); return; } }; - match db.group_set_flags(name, &canonical, &updated) { - Ok(()) => ctx.notice(me, from.uid, format!("\x02{canonical}\x02 in \x02{name}\x02 now holds \x02{}\x02.", if updated.is_empty() { "(member)" } else { &updated })), + let letters = updated.to_letters(); + match db.group_set_flags(name, &canonical, &letters) { + Ok(()) => ctx.notice(me, from.uid, format!("\x02{canonical}\x02 in \x02{name}\x02 now holds \x02{}\x02.", if letters.is_empty() { "(member)" } else { &letters })), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } diff --git a/modules/groupserv/src/lib.rs b/modules/groupserv/src/lib.rs index 29a7f0e..2ab81ad 100644 --- a/modules/groupserv/src/lib.rs +++ b/modules/groupserv/src/lib.rs @@ -85,6 +85,12 @@ fn account<'a>(me: &str, from: &'a Sender, ctx: &mut ServiceCtx) -> Option<&'a s // Whether `who` may manage `group` (founder, or holds the F/f flag). fn can_manage(group: &echo_api::GroupView, who: &str) -> bool { + use echo_api::{GroupFlag, GroupFlags}; group.founder.eq_ignore_ascii_case(who) - || group.members.iter().any(|m| m.account.eq_ignore_ascii_case(who) && (m.flags.contains('F') || m.flags.contains('f'))) + || group.members.iter().any(|m| { + m.account.eq_ignore_ascii_case(who) && { + let f = GroupFlags::parse(&m.flags); + f.has(GroupFlag::Founder) || f.has(GroupFlag::Manage) + } + }) } diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index 2b620a7..cd4a3bb 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -500,7 +500,9 @@ impl Db { let k = key(name); self.net.groups.iter().find(|g| key(&g.name) == k).is_some_and(|g| { g.founder.eq_ignore_ascii_case(account) - || g.members.iter().any(|m| m.account.eq_ignore_ascii_case(account) && m.flags.contains('c')) + || g.members.iter().any(|m| { + m.account.eq_ignore_ascii_case(account) && echo_api::GroupFlags::parse(&m.flags).has(echo_api::GroupFlag::Channel) + }) }) }