use std::collections::BTreeSet;
use std::sync::Arc;
use ikigai_core::{is_deny_scope, Capability, Error, Result};
pub const CAP_READ: &str = "urn:cap:script:read:*";
pub const CAP_WRITE: &str = "urn:cap:script:write:*";
pub const CAP_DELETE: &str = "urn:cap:script:delete:*";
pub const CAP_RUN: &str = "urn:cap:script:run:*";
pub const CAP_ANY: &str = "urn:cap:script:*";
pub const CAP_RUN_PUBLIC: &str = "urn:cap:script:run:public";
pub const CAP_READ_PUBLIC: &str = "urn:cap:script:read:public";
pub const CAP_LISP: &str = "urn:cap:lisp";
pub fn cap_read(name: &str) -> String {
format!("urn:cap:script:read:{name}")
}
pub fn cap_write(name: &str) -> String {
format!("urn:cap:script:write:{name}")
}
pub fn cap_delete(name: &str) -> String {
format!("urn:cap:script:delete:{name}")
}
pub fn cap_run(name: &str) -> String {
format!("urn:cap:script:run:{name}")
}
pub const UNSTAMPED: &str = "urn:script:principal:unstamped";
pub const ANONYMOUS: &str = "urn:script:principal:anonymous";
pub fn principal_of(capability: &Capability) -> String {
capability.principal().unwrap_or(UNSTAMPED).to_string()
}
pub fn is_identity(principal: &str) -> bool {
!principal.trim().is_empty() && principal != UNSTAMPED && principal != ANONYMOUS
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Ceiling {
Unbounded,
Scopes(BTreeSet<String>),
}
impl Ceiling {
pub fn unbounded() -> Ceiling {
Ceiling::Unbounded
}
pub fn nothing() -> Ceiling {
Ceiling::Scopes(BTreeSet::new())
}
pub fn scoped<I, S>(scopes: I) -> Ceiling
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Ceiling::Scopes(scopes.into_iter().map(Into::into).collect())
}
pub fn parse(text: &str) -> Result<Ceiling> {
let mut scopes = BTreeSet::new();
for (number, line) in text.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
if line == "*" {
return Ok(Ceiling::Unbounded);
}
if !line.starts_with("urn:cap:") || line.split_whitespace().count() != 1 {
return Err(Error::InvalidArgument {
name: "ceiling".to_string(),
detail: format!(
"line {}: `{line}` is not one `urn:cap:` scope (or `*` for no ceiling)",
number + 1
),
});
}
scopes.insert(line.to_string());
}
Ok(Ceiling::Scopes(scopes))
}
pub fn allows(&self, scope: &str) -> bool {
match self {
Ceiling::Unbounded => !is_deny_scope(scope),
Ceiling::Scopes(held) => {
!is_deny_scope(scope)
&& held.iter().any(|h| match h.strip_suffix('*') {
Some(prefix) => scope.starts_with(prefix),
None => h == scope,
})
}
}
}
pub fn exclusions(&self) -> Vec<String> {
match self {
Ceiling::Unbounded => Vec::new(),
Ceiling::Scopes(held) => held.iter().filter(|s| is_deny_scope(s)).cloned().collect(),
}
}
}
pub type CeilingPolicy = Arc<dyn Fn(&str) -> Ceiling + Send + Sync>;
pub fn same_for_all(ceiling: Ceiling) -> CeilingPolicy {
Arc::new(move |_| ceiling.clone())
}
pub fn parse_requires(text: &str) -> Result<BTreeSet<String>> {
let mut scopes = BTreeSet::new();
for scope in text.split_whitespace() {
let detail = if !scope.starts_with("urn:cap:") {
Some("is not a `urn:cap:` scope")
} else if scope.ends_with('*') {
Some(
"is a family wildcard; a script declares the exact grants it needs, \
because a run keeps grants by exact name",
)
} else if is_deny_scope(scope) {
Some("is an exclusion; a script declares what it needs, not what it may not touch")
} else {
None
};
if let Some(detail) = detail {
return Err(Error::InvalidArgument {
name: "requires".to_string(),
detail: format!("`{scope}` {detail}"),
});
}
scopes.insert(scope.to_string());
}
Ok(scopes)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Grant {
pub granted: BTreeSet<String>,
pub exclusions: BTreeSet<String>,
}
fn family(scope: &str) -> Option<&str> {
scope.strip_suffix('*')
}
pub fn grant_at_publish(publisher: &Capability, declared: &BTreeSet<String>) -> Result<Grant> {
let held_under = |prefix: &str| -> Vec<String> {
publisher
.scopes()
.map(|held| {
held.iter()
.filter(|s| s.starts_with(prefix) && !is_deny_scope(s))
.cloned()
.collect()
})
.unwrap_or_default()
};
let mut granted = BTreeSet::new();
let mut missing: Vec<&str> = Vec::new();
for scope in declared {
match family(scope) {
Some(_) if publisher.is_root() => {
granted.insert(scope.clone());
}
Some(prefix) => {
let held = held_under(prefix);
if held.is_empty() {
missing.push(scope);
}
granted.extend(held);
}
None if publisher.allows(scope) => {
granted.insert(scope.clone());
}
None => missing.push(scope),
}
}
if !missing.is_empty() {
return Err(Error::Denied(format!(
"a script cannot be given more than its publisher holds, and this capability \
does not hold {} — declare less, or publish under a capability that holds it",
missing.join(", ")
)));
}
let exclusions = publisher
.scopes()
.map(|held| held.iter().filter(|s| is_deny_scope(s)).cloned().collect())
.unwrap_or_default();
Ok(Grant {
granted,
exclusions,
})
}
pub fn effective(
declared: &BTreeSet<String>,
granted: &BTreeSet<String>,
exclusions: &BTreeSet<String>,
ceiling: &Ceiling,
) -> BTreeSet<String> {
let mut keep: BTreeSet<String> = BTreeSet::new();
for scope in declared {
match family(scope) {
Some(_) if granted.contains(scope) => {
keep.insert(scope.clone());
}
Some(prefix) => keep.extend(
granted
.iter()
.filter(|g| g.starts_with(prefix) && ceiling.allows(g))
.cloned(),
),
None if granted.contains(scope) && ceiling.allows(scope) => {
keep.insert(scope.clone());
}
None => {}
}
}
keep.extend(exclusions.iter().filter(|s| is_deny_scope(s)).cloned());
keep.extend(ceiling.exclusions());
keep
}
#[cfg(test)]
mod tests {
use super::*;
fn set(xs: &[&str]) -> BTreeSet<String> {
xs.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_publisher_cannot_declare_what_they_do_not_hold() {
let publisher = Capability::scoped(["urn:cap:lisp", "urn:cap:script:write:x"]);
let err =
grant_at_publish(&publisher, &set(&["urn:cap:lisp", "urn:cap:mail:send"])).unwrap_err();
assert!(
matches!(&err, Error::Denied(m) if m.contains("urn:cap:mail:send") && !m.contains("urn:cap:lisp,")),
"{err:?}"
);
}
#[test]
fn the_publishers_exclusions_are_taken_with_the_grant() {
let publisher = Capability::scoped([
"urn:cap:lisp",
"urn:cap:fs:read:/root",
"urn:cap:fs:read:-/root/secret",
]);
let grant =
grant_at_publish(&publisher, &set(&["urn:cap:lisp", "urn:cap:fs:read:/root"])).unwrap();
assert_eq!(grant.exclusions, set(&["urn:cap:fs:read:-/root/secret"]));
let grant = grant_at_publish(&Capability::root(), &set(&["urn:cap:anything"])).unwrap();
assert!(grant.exclusions.is_empty());
}
#[test]
fn a_hand_widened_grant_still_cannot_exceed_the_stored_snapshot() {
let request = effective(
&set(&["urn:cap:lisp", "urn:cap:mail:send"]),
&set(&["urn:cap:lisp"]),
&BTreeSet::new(),
&Ceiling::Unbounded,
);
assert_eq!(request, set(&["urn:cap:lisp"]));
}
#[test]
fn a_family_takes_the_publishers_grants_under_it() {
let family = "urn:cap:store:read:graph:*";
let publisher = Capability::scoped([
"urn:cap:store:read:graph:urn:a",
"urn:cap:store:read:graph:urn:b",
"urn:cap:other",
]);
let grant = grant_at_publish(&publisher, &set(&[family])).unwrap();
assert_eq!(
grant.granted,
set(&[
"urn:cap:store:read:graph:urn:a",
"urn:cap:store:read:graph:urn:b"
])
);
assert!(grant_at_publish(&Capability::scoped(["urn:cap:other"]), &set(&[family])).is_err());
let root = grant_at_publish(&Capability::root(), &set(&[family])).unwrap();
assert_eq!(root.granted, set(&[family]));
let keep = effective(
&set(&[family]),
&grant.granted,
&BTreeSet::new(),
&Ceiling::scoped(["urn:cap:store:read:graph:urn:a"]),
);
assert_eq!(keep, set(&["urn:cap:store:read:graph:urn:a"]));
}
#[test]
fn requires_refuses_wildcards_exclusions_and_strangers() {
assert!(parse_requires("urn:cap:net:*").is_err());
assert!(parse_requires("urn:cap:fs:read:-/x").is_err());
assert!(parse_requires("lisp").is_err());
assert_eq!(
parse_requires(" urn:cap:a\n urn:cap:b urn:cap:a ").unwrap(),
set(&["urn:cap:a", "urn:cap:b"])
);
}
#[test]
fn a_ceiling_never_admits_an_exclusion_as_a_grant() {
assert!(!Ceiling::Unbounded.allows("urn:cap:net:-x"));
assert!(!Ceiling::scoped(["urn:cap:net:*"]).allows("urn:cap:net:-x"));
assert!(!Ceiling::nothing().allows("urn:cap:lisp"));
}
}