use serde::{Deserialize, Serialize};
const EVERYTHING: &str = "*";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Abilities(Vec<String>);
impl Abilities {
pub fn none() -> Self {
Self(Vec::new())
}
pub fn everything() -> Self {
Self(vec![EVERYTHING.to_string()])
}
pub fn new(abilities: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self(abilities.into_iter().map(Into::into).collect())
}
pub fn parse(stored: &str) -> Self {
Self(
stored
.split(',')
.map(str::trim)
.filter(|ability| !ability.is_empty())
.map(str::to_string)
.collect(),
)
}
pub fn to_csv(&self) -> String {
self.0.join(",")
}
pub fn can(&self, ability: &str) -> bool {
self.0.iter().any(|held| grants(held, ability))
}
pub fn cannot(&self, ability: &str) -> bool {
!self.can(ability)
}
pub fn can_all<'a>(&self, abilities: impl IntoIterator<Item = &'a str>) -> bool {
abilities.into_iter().all(|ability| self.can(ability))
}
pub fn can_any<'a>(&self, abilities: impl IntoIterator<Item = &'a str>) -> bool {
abilities.into_iter().any(|ability| self.can(ability))
}
pub fn is_unrestricted(&self) -> bool {
self.0.iter().any(|held| held == EVERYTHING)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn as_slice(&self) -> &[String] {
&self.0
}
}
fn grants(held: &str, wanted: &str) -> bool {
if held == EVERYTHING || held == wanted {
return true;
}
match held.strip_suffix('*') {
Some(prefix) if !prefix.is_empty() => wanted.starts_with(prefix),
_ => false,
}
}
impl std::fmt::Display for Abilities {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.to_csv())
}
}
impl From<&str> for Abilities {
fn from(stored: &str) -> Self {
Self::parse(stored)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_exact_ability_grants_itself_and_nothing_else() {
let abilities = Abilities::new(["posts:read"]);
assert!(abilities.can("posts:read"));
assert!(abilities.cannot("posts:write"));
assert!(abilities.cannot("posts"));
assert!(abilities.cannot(""));
}
#[test]
fn the_everything_wildcard_grants_everything() {
let abilities = Abilities::everything();
assert!(abilities.can("posts:read"));
assert!(abilities.can("anything at all"));
assert!(abilities.is_unrestricted());
}
#[test]
fn a_namespace_wildcard_stops_at_its_prefix() {
let abilities = Abilities::new(["posts:*"]);
assert!(abilities.can("posts:read"));
assert!(abilities.can("posts:write"));
assert!(abilities.can("posts:"), "the prefix itself");
assert!(abilities.cannot("postscript:read"));
assert!(abilities.cannot("comments:read"));
assert!(!abilities.is_unrestricted());
}
#[test]
fn no_abilities_grants_nothing() {
let abilities = Abilities::none();
assert!(abilities.cannot("posts:read"));
assert!(abilities.cannot("*"), "asking for the wildcard is not holding it");
assert!(abilities.is_empty());
}
#[test]
fn matching_is_exact_rather_than_lenient() {
let abilities = Abilities::new(["posts:read"]);
assert!(abilities.cannot("Posts:Read"));
assert!(abilities.cannot("posts:read "));
assert!(abilities.cannot(" posts:read"));
}
#[test]
fn a_stored_column_round_trips() {
let abilities = Abilities::parse("posts:read, posts:write,");
assert_eq!(abilities.as_slice(), ["posts:read", "posts:write"]);
assert_eq!(abilities.to_csv(), "posts:read,posts:write");
assert_eq!(Abilities::parse(&abilities.to_csv()), abilities);
}
#[test]
fn an_empty_column_is_no_abilities_rather_than_one_blank_one() {
for stored in ["", " ", ",", " , "] {
let abilities = Abilities::parse(stored);
assert!(abilities.is_empty(), "{stored:?}");
assert!(abilities.cannot(""), "{stored:?}");
}
}
#[test]
fn all_and_any_do_what_they_say() {
let abilities = Abilities::new(["posts:read", "comments:*"]);
assert!(abilities.can_all(["posts:read", "comments:write"]));
assert!(!abilities.can_all(["posts:read", "posts:write"]));
assert!(abilities.can_any(["posts:write", "posts:read"]));
assert!(!abilities.can_any(["posts:write", "users:read"]));
}
#[test]
fn any_of_nothing_is_not_a_grant() {
let abilities = Abilities::everything();
assert!(!abilities.can_any(std::iter::empty()));
assert!(abilities.can_all(std::iter::empty()), "all of nothing is vacuously true");
}
#[test]
fn it_displays_as_what_would_be_stored() {
assert_eq!(Abilities::new(["a", "b"]).to_string(), "a,b");
assert_eq!(Abilities::none().to_string(), "");
}
}