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`)
|
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
|
/// 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
|
/// 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`].
|
/// 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
|
flag_set!(
|
||||||
/// [`Privs`]. The typed form of a stored flag string like "otia".
|
/// The set of channel-access flags an entry holds — the typed form of a stored
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
/// flag string like "otia". Same bitset primitive as [`Privs`] / [`GroupFlags`].
|
||||||
pub struct Flags(u16);
|
Flags: Flag = u16
|
||||||
|
);
|
||||||
|
|
||||||
impl Flags {
|
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
|
/// 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 —
|
/// flags, anything else is parsed as a flag string. (The `founder` preset —
|
||||||
/// the channel owner — is handled in `level_caps`, not here.)
|
/// 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.
|
/// The capabilities these flags confer.
|
||||||
pub fn caps(self) -> Caps {
|
pub fn caps(self) -> Caps {
|
||||||
let founderish = self.has(Flag::Founder);
|
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
|
// The granular group-membership flags (GroupServ), same primitive, different
|
||||||
// letters: F founder, f modify the member/flags list, i invite members, s set
|
// letters (see `GroupFlag`).
|
||||||
// group options, c the group may be granted channel access, m group memos.
|
|
||||||
pub const GROUP_FLAGS: &str = "Fficsm";
|
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 {
|
impl Caps {
|
||||||
// Combine two capability sets (a direct access entry and a group's), taking
|
// 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.
|
// the union of privileges, the higher rank, and the strongest auto-mode.
|
||||||
|
|
@ -953,28 +1001,6 @@ pub struct GroupView {
|
||||||
pub members: Vec<GroupMemberView>,
|
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).
|
// One auto-kick entry (a hostmask and the reason shown on kick).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ChanAkickView {
|
pub struct ChanAkickView {
|
||||||
|
|
@ -1817,17 +1843,18 @@ mod tests {
|
||||||
assert_eq!(Flags::from_level("sop").caps().rank, Rank::Admin);
|
assert_eq!(Flags::from_level("sop").caps().rank, Rank::Admin);
|
||||||
assert_eq!(Flags::from_level("halfop").to_letters(), "h");
|
assert_eq!(Flags::from_level("halfop").to_letters(), "h");
|
||||||
assert_eq!(Flags::from_level("oa").caps().auto, Some("+ao"), "op+access = protected admin");
|
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]
|
#[test]
|
||||||
fn access_flags_apply_and_resolve() {
|
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.
|
// Legacy presets and flag strings both resolve to capabilities.
|
||||||
assert_eq!(level_caps("op").auto, Some("+o"));
|
assert_eq!(level_caps("op").auto, Some("+o"));
|
||||||
assert!(level_caps("op").op && level_caps("op").topic);
|
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.
|
// FLAGS <!group> [account [+/-flags]]: list, show, or change group-access flags.
|
||||||
// Listing/showing is open; changing needs the founder or the `f` flag.
|
// 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."));
|
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
|
||||||
return;
|
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,
|
Ok(f) => f,
|
||||||
Err(bad) => {
|
Err(bad) => {
|
||||||
ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid group flag. Valid: \x02{GROUP_FLAGS}\x02."));
|
ctx.notice(me, from.uid, format!("\x02{bad}\x02 isn't a valid group flag. Valid: \x02{GROUP_FLAGS}\x02."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match db.group_set_flags(name, &canonical, &updated) {
|
let letters = updated.to_letters();
|
||||||
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 })),
|
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."),
|
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).
|
// Whether `who` may manage `group` (founder, or holds the F/f flag).
|
||||||
fn can_manage(group: &echo_api::GroupView, who: &str) -> bool {
|
fn can_manage(group: &echo_api::GroupView, who: &str) -> bool {
|
||||||
|
use echo_api::{GroupFlag, GroupFlags};
|
||||||
group.founder.eq_ignore_ascii_case(who)
|
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);
|
let k = key(name);
|
||||||
self.net.groups.iter().find(|g| key(&g.name) == k).is_some_and(|g| {
|
self.net.groups.iter().find(|g| key(&g.name) == k).is_some_and(|g| {
|
||||||
g.founder.eq_ignore_ascii_case(account)
|
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