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}")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Act {
Read,
Write,
Delete,
Run,
}
impl Act {
pub const ALL: [Act; 4] = [Act::Read, Act::Write, Act::Delete, Act::Run];
pub fn as_str(self) -> &'static str {
match self {
Act::Read => "read",
Act::Write => "write",
Act::Delete => "delete",
Act::Run => "run",
}
}
fn prefix(self) -> String {
format!("urn:cap:script:{}:", self.as_str())
}
}
pub fn cap(act: Act, name: &str) -> String {
format!("{}{name}", act.prefix())
}
pub fn cap_namespace(act: Act, namespace: &str) -> Result<String> {
if !is_namespace(namespace) {
return Err(Error::InvalidArgument {
name: "namespace".to_string(),
detail: format!(
"`{namespace}` is not a namespace: one or more `-`-separated words of lowercase \
letters, digits, `-` and `_`, starting with a letter or digit, at most {} bytes",
crate::name::MAX_NAME - 1
),
});
}
Ok(format!("{}{namespace}-*", act.prefix()))
}
fn is_namespace(namespace: &str) -> bool {
let mut chars = namespace.chars();
namespace.len() < crate::name::MAX_NAME
&& chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
pub fn holds(capability: &Capability, act: Act, name: &str) -> bool {
let Some(held) = capability.scopes() else {
return true;
};
if excluded(capability, act, name) {
return false;
}
if capability.allows(&cap(act, name)) {
return true;
}
let prefix = act.prefix();
held.iter()
.filter_map(|scope| scope.strip_prefix(prefix.as_str()))
.filter_map(|rule| rule.strip_suffix('*'))
.any(|covered| {
covered
.strip_suffix('-')
.is_some_and(|namespace| is_namespace(namespace) && name.starts_with(covered))
})
}
pub fn excluded(capability: &Capability, act: Act, name: &str) -> bool {
let Some(held) = capability.scopes() else {
return false;
};
let prefix = act.prefix();
held.iter()
.filter_map(|scope| scope.strip_prefix(prefix.as_str()))
.filter_map(|rule| rule.strip_prefix('-'))
.any(|rule| match rule.strip_suffix('*') {
Some(covered) => name.starts_with(covered),
None => rule == name,
})
}
pub fn run_floor(name: &str) -> String {
match name.split_once('-') {
Some((top, _)) => format!("{}{top}-*", Act::Run.prefix()),
None => cap_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
}
pub const BUDGET_PREFIX: &str = "urn:cap:store:budget:";
pub const MAX_BUDGET_MS: u64 = 120_000;
pub fn cap_budget(millis: u64) -> String {
format!("{BUDGET_PREFIX}{millis}")
}
fn budget_grants(capability: &Capability) -> BTreeSet<u64> {
capability
.scopes()
.map(|held| {
held.iter()
.filter(|scope| capability.allows(scope))
.filter_map(|scope| scope.strip_prefix(BUDGET_PREFIX)?.parse::<u64>().ok())
.filter(|millis| *millis > 0)
.collect()
})
.unwrap_or_default()
}
pub fn held_budget(capability: &Capability) -> Option<u64> {
if capability.is_root() {
return Some(MAX_BUDGET_MS);
}
budget_grants(capability).last().copied()
}
pub fn parse_budget(text: &str) -> Result<u64> {
match text.trim().parse::<u64>() {
Ok(millis) if (1..=MAX_BUDGET_MS).contains(&millis) => Ok(millis),
_ => Err(Error::InvalidArgument {
name: "budget".to_string(),
detail: format!(
"`{text}` is not a store time budget: give a positive whole number of \
milliseconds, at most {MAX_BUDGET_MS} (the store's ceiling)"
),
}),
}
}
pub fn budget_at_publish(publisher: &Capability, declared: u64) -> Result<u64> {
match held_budget(publisher) {
Some(held) if held >= declared => Ok(held),
held => Err(Error::Denied(format!(
"a script cannot be given more store time than its publisher holds: it declares \
budget={declared} ms, and this capability holds {} — declare less, or publish \
under a capability holding `{}`",
match held {
Some(held) => format!("`{}` ({held} ms) at most", cap_budget(held)),
None => "no store budget grant (only the store's base)".to_string(),
},
cap_budget(declared)
))),
}
}
impl Ceiling {
pub fn budget(&self) -> Option<u64> {
match self {
Ceiling::Unbounded => Some(MAX_BUDGET_MS),
Ceiling::Scopes(held) => {
let family = held.iter().any(|h| {
h.strip_suffix('*')
.is_some_and(|prefix| BUDGET_PREFIX.starts_with(prefix))
});
if family {
return Some(MAX_BUDGET_MS);
}
held.iter()
.filter_map(|h| h.strip_prefix(BUDGET_PREFIX)?.parse::<u64>().ok())
.filter(|millis| *millis > 0)
.max()
}
}
}
}
pub fn budget_bound(declared: Option<u64>, granted: Option<u64>, ceiling: &Ceiling) -> Option<u64> {
Some(declared?.min(granted?).min(ceiling.budget()?))
}
pub fn effective_budget(
declared: Option<u64>,
granted: Option<u64>,
ceiling: &Ceiling,
runner: &Capability,
) -> Option<u64> {
Some(budget_bound(declared, granted, ceiling)?.min(held_budget(runner)?))
}
pub fn budget_grant(runner: &Capability, millis: u64) -> Option<String> {
if runner.is_root() {
return Some(cap_budget(millis));
}
budget_grants(runner)
.range(..=millis)
.next_back()
.map(|held| cap_budget(*held))
}
pub fn budget_grant_lowered(runner: &Capability, millis: u64) -> Option<String> {
if runner.is_root() {
return Some(cap_budget(millis));
}
let held = budget_grants(runner);
held.range(millis..)
.next()
.or_else(|| held.last())
.map(|held| cap_budget(*held))
}
#[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_runs_budget_is_the_least_of_four() {
let runner = |ms: u64| Capability::scoped([cap_budget(ms)]);
let none = Capability::scoped(["urn:cap:script:run:x"]);
let root = Capability::root();
let open = Ceiling::Unbounded;
let host = |ms: u64| Ceiling::scoped([cap_budget(ms), "urn:cap:lisp".to_string()]);
let silent = Ceiling::scoped(["urn:cap:lisp"]);
let check = |what: &str,
declared: Option<u64>,
granted: Option<u64>,
ceiling: &Ceiling,
runner: &Capability,
want: Option<u64>| {
assert_eq!(
effective_budget(declared, granted, ceiling, runner),
want,
"{what}"
);
};
check("no declaration", None, None, &open, &runner(900), None);
check(
"no declaration, root runner",
None,
None,
&open,
&root,
None,
);
check(
"declared, held",
Some(900),
Some(900),
&open,
&runner(900),
Some(900),
);
check(
"above the runner",
Some(900),
Some(900),
&open,
&runner(400),
Some(400),
);
check(
"runner holds no grant",
Some(900),
Some(900),
&open,
&none,
None,
);
check(
"above the publisher",
Some(900),
Some(600),
&open,
&runner(900),
Some(600),
);
check(
"above the ceiling",
Some(900),
Some(900),
&host(700),
&runner(900),
Some(700),
);
check(
"a ceiling naming no budget",
Some(900),
Some(900),
&silent,
&runner(900),
None,
);
check("root runner", Some(900), Some(900), &open, &root, Some(900));
check(
"root publisher",
Some(900),
Some(MAX_BUDGET_MS),
&open,
&runner(5000),
Some(900),
);
check(
"all root, ceiling lower",
Some(90_000),
Some(MAX_BUDGET_MS),
&host(30_000),
&root,
Some(30_000),
);
assert_eq!(
budget_at_publish(&root, MAX_BUDGET_MS).unwrap(),
MAX_BUDGET_MS
);
assert!(parse_budget(&(MAX_BUDGET_MS + 1).to_string()).is_err());
assert!(parse_budget("0").is_err());
assert!(parse_budget("1s").is_err());
}
#[test]
fn an_exclusion_is_never_read_as_a_budget() {
let odd = Capability::scoped(["urn:cap:store:budget:-900", "urn:cap:store:budget:0"]);
assert_eq!(held_budget(&odd), None);
}
#[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"));
}
}