use std::collections::BTreeSet;
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
pub const KNOWN_DOMAINS: &[&str] = &["fs", "net", "proc", "env", "secret", "time", "rand"];
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct GrantAtom {
pub domain: String,
pub action: String,
pub resource: String,
}
impl GrantAtom {
pub fn new(
domain: impl Into<String>,
action: impl Into<String>,
resource: impl Into<String>,
) -> Self {
Self {
domain: domain.into(),
action: action.into(),
resource: resource.into(),
}
}
pub fn is_template(&self) -> bool {
self.resource.contains('{') && self.resource.contains('}')
}
pub fn is_bare(&self) -> bool {
self.resource.is_empty()
}
pub fn has_known_domain(&self) -> bool {
KNOWN_DOMAINS.contains(&self.domain.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GrantAtomParseError {
#[error("a grant atom cannot be empty")]
Empty,
#[error("expected `domain:action[:resource]`, found {input:?} with no action")]
MissingAction {
input: String,
},
#[error("the {segment} segment of a grant atom cannot be empty")]
EmptySegment {
segment: &'static str,
},
#[error("the {segment} segment must not contain whitespace, found {ch:?}")]
Whitespace {
segment: &'static str,
ch: char,
},
}
impl FromStr for GrantAtom {
type Err = GrantAtomParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.trim().is_empty() {
return Err(GrantAtomParseError::Empty);
}
let mut parts = s.splitn(3, ':');
let domain = parts.next().unwrap_or_default();
let Some(action) = parts.next() else {
return Err(GrantAtomParseError::MissingAction {
input: s.to_string(),
});
};
let resource = parts.next().unwrap_or("");
check_segment(domain, "domain")?;
check_segment(action, "action")?;
Ok(Self::new(domain, action, resource))
}
}
fn check_segment(value: &str, segment: &'static str) -> Result<(), GrantAtomParseError> {
if value.is_empty() {
return Err(GrantAtomParseError::EmptySegment { segment });
}
if let Some(ch) = value.chars().find(|c| c.is_whitespace()) {
return Err(GrantAtomParseError::Whitespace { segment, ch });
}
Ok(())
}
impl fmt::Display for GrantAtom {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_bare() {
write!(f, "{}:{}", self.domain, self.action)
} else {
write!(f, "{}:{}:{}", self.domain, self.action, self.resource)
}
}
}
impl fmt::Debug for GrantAtom {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self}")
}
}
#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct GrantSet(BTreeSet<GrantAtom>);
impl GrantSet {
pub const EMPTY: Self = Self(BTreeSet::new());
pub fn new(atoms: impl IntoIterator<Item = GrantAtom>) -> Self {
Self(atoms.into_iter().collect())
}
pub fn parse<'a>(
atoms: impl IntoIterator<Item = &'a str>,
) -> Result<Self, GrantAtomParseError> {
atoms
.into_iter()
.map(GrantAtom::from_str)
.collect::<Result<BTreeSet<_>, _>>()
.map(Self)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn iter(&self) -> impl Iterator<Item = &GrantAtom> {
self.0.iter()
}
pub fn contains_atom(&self, atom: &GrantAtom) -> bool {
self.0.contains(atom)
}
pub fn has_templates(&self) -> bool {
self.0.iter().any(GrantAtom::is_template)
}
}
impl FromIterator<GrantAtom> for GrantSet {
fn from_iter<I: IntoIterator<Item = GrantAtom>>(iter: I) -> Self {
Self::new(iter)
}
}
impl<'a> IntoIterator for &'a GrantSet {
type Item = &'a GrantAtom;
type IntoIter = std::collections::btree_set::Iter<'a, GrantAtom>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}
impl fmt::Debug for GrantSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_set().entries(self.0.iter()).finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canonical::Canonical;
fn atoms() -> Vec<GrantAtom> {
vec![
GrantAtom::new("fs", "read", "/src/**"),
GrantAtom::new("net", "egress", "api.example.com:443"),
]
}
#[test]
fn default_grants_nothing() {
assert!(GrantSet::default().is_empty());
assert!(GrantSet::EMPTY.is_empty());
}
#[test]
fn insertion_order_does_not_change_identity() {
let forward = GrantSet::new(atoms());
let reverse = GrantSet::new(atoms().into_iter().rev());
assert_eq!(forward, reverse);
assert_eq!(forward.digest().unwrap(), reverse.digest().unwrap());
}
#[test]
fn duplicates_collapse() {
let mut with_dupes = atoms();
with_dupes.extend(atoms());
assert_eq!(GrantSet::new(with_dupes).len(), 2);
}
#[test]
fn templates_are_detected() {
assert!(GrantAtom::new("fs", "read", "{path}").is_template());
assert!(!GrantAtom::new("fs", "read", "/src/**").is_template());
assert!(GrantSet::new([GrantAtom::new("fs", "read", "{path}")]).has_templates());
assert!(!GrantSet::new(atoms()).has_templates());
}
#[test]
fn parses_the_documented_three_part_form() {
let a: GrantAtom = "fs:read:/src/**".parse().unwrap();
assert_eq!(a, GrantAtom::new("fs", "read", "/src/**"));
}
#[test]
fn a_resource_may_itself_contain_colons() {
let a: GrantAtom = "net:egress:api.example.com:443".parse().unwrap();
assert_eq!(a.domain, "net");
assert_eq!(a.action, "egress");
assert_eq!(a.resource, "api.example.com:443");
}
#[test]
fn parses_the_bare_two_part_form() {
for s in ["time:now", "rand:bytes"] {
let a: GrantAtom = s.parse().unwrap();
assert!(a.is_bare(), "{s} should have no resource");
assert_eq!(
a.to_string(),
s,
"bare atoms round-trip without a trailing colon"
);
}
assert_eq!(
"time:now:".parse::<GrantAtom>().unwrap(),
"time:now".parse::<GrantAtom>().unwrap()
);
}
#[test]
fn every_documented_domain_parses() {
let documented = [
"fs:read:/src/**",
"fs:write:/build/**",
"net:egress:api.example.com:443",
"proc:exec:git",
"env:read:HOME",
"secret:use:stripe_key",
"time:now",
"rand:bytes",
];
for s in documented {
let a: GrantAtom = s.parse().unwrap_or_else(|e| panic!("{s} failed: {e}"));
assert!(a.has_known_domain(), "{s} should use a documented domain");
}
}
#[test]
fn display_round_trips_through_parse() {
for s in [
"fs:read:/src/**",
"net:egress:api.example.com:443",
"fs:read:{path}",
"time:now",
"fs:read:/a path/with spaces/**",
] {
let a: GrantAtom = s.parse().unwrap();
assert_eq!(a.to_string().parse::<GrantAtom>().unwrap(), a);
}
}
#[test]
fn glob_and_template_resources_are_preserved_verbatim() {
assert_eq!(
"fs:read:/src/**".parse::<GrantAtom>().unwrap().resource,
"/src/**"
);
assert_eq!(
"fs:read:/src/*".parse::<GrantAtom>().unwrap().resource,
"/src/*"
);
let t: GrantAtom = "fs:read:{path}".parse().unwrap();
assert_eq!(t.resource, "{path}");
assert!(t.is_template(), "a parsed template is marked symbolic");
}
#[test]
fn an_unknown_domain_parses_but_is_flagged() {
let a: GrantAtom = "gpu:use:cuda0".parse().unwrap();
assert!(!a.has_known_domain());
}
#[test]
fn malformed_atoms_name_the_offending_segment() {
assert_eq!("".parse::<GrantAtom>(), Err(GrantAtomParseError::Empty));
assert_eq!(" ".parse::<GrantAtom>(), Err(GrantAtomParseError::Empty));
assert!(matches!(
"fsread".parse::<GrantAtom>(),
Err(GrantAtomParseError::MissingAction { .. })
));
assert_eq!(
":read:/src".parse::<GrantAtom>(),
Err(GrantAtomParseError::EmptySegment { segment: "domain" })
);
assert_eq!(
"fs::/src".parse::<GrantAtom>(),
Err(GrantAtomParseError::EmptySegment { segment: "action" })
);
assert_eq!(
"f s:read:/src".parse::<GrantAtom>(),
Err(GrantAtomParseError::Whitespace {
segment: "domain",
ch: ' '
})
);
}
#[test]
fn a_set_parses_from_strings() {
let g = GrantSet::parse(["fs:read:/src/**", "net:egress:api.example.com:443"]).unwrap();
assert_eq!(g.len(), 2);
assert!(GrantSet::parse(["fs:read:/src/**", "bad"]).is_err());
}
}