api: type GroupServ flags (GroupFlag/GroupFlags via a shared flag_set! macro); delete the stringly apply_flags
All checks were successful
CI / check (push) Successful in 4m0s
All checks were successful
CI / check (push) Successful in 4m0s
This commit is contained in:
parent
2a12ebd6e3
commit
bc4fada354
4 changed files with 121 additions and 85 deletions
185
api/src/lib.rs
185
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<Self, char> {
|
||||
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<Flags, char> {
|
||||
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<GroupFlag> {
|
||||
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<GroupMemberView>,
|
||||
}
|
||||
|
||||
// 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<String, char> {
|
||||
let mut set: Vec<char> = 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::<String>());
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use echo_api::{apply_flags, Sender, ServiceCtx, Store, GROUP_FLAGS};
|
||||
use echo_api::{GroupFlags, Sender, ServiceCtx, Store, GROUP_FLAGS};
|
||||
|
||||
// FLAGS <!group> [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."),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue