Skip to main content

ikigai_script/
authority.rs

1//! The three authorities, never merged — and the one computation that decides what a run
2//! may touch.
3//!
4//! 1. **Publish or change**: `urn:cap:script:write:{name}`, and `urn:cap:script:delete:{name}`
5//!    to retire.
6//! 2. **Run**: `urn:cap:script:run:{name}`. A **public** script is also runnable by any
7//!    holder of [`CAP_RUN_PUBLIC`], which is the grant a host gives its anonymous
8//!    principal.
9//! 3. **Runs as**: what [`effective`] computes, applied to the runner's own capability by
10//!    attenuation. Never minted.
11//!
12//! Reading a script without running it is a fourth scope, `urn:cap:script:read:{name}`
13//! (or [`CAP_READ_PUBLIC`] for a public, published script): source code is not the same
14//! sensitivity as running it, the same split `ikigai-ledger` draws between read and write.
15//!
16//! Each of the four is held for ONE script (`urn:cap:script:{act}:{name}`) or for a
17//! NAMESPACE of them (`urn:cap:script:{act}:{namespace}-*`, [`cap_namespace`]: every name
18//! that begins `{namespace}-`), and an exclusion (`…:{act}:-{name}`, `…:{act}:-{prefix}*`)
19//! takes a script back out. [`holds`] is the one place that rule lives.
20//!
21//! # Runs as: the least of four
22//!
23//! A run executes under
24//!
25//! ```text
26//! runner.attenuate( { s in declared : s in granted-at-publish and ceiling.allows(s) }
27//!                   + the publisher's exclusions + the ceiling's exclusions )
28//! ```
29//!
30//! - **declared**: what the script says it needs (`requires=` at publish), plus the
31//!   capability its LANGUAGE needs (`urn:cap:lisp` for Lisp), because evaluating code is
32//!   itself authority.
33//! - **granted at publish**: the part of `declared` the publisher held when they published.
34//!   **A publish that declares a scope the publisher does not hold is REFUSED** with a typed
35//!   `Denied` naming the scopes. Narrowing silently instead would store a script whose
36//!   `requires` says one thing and whose runs get another, and the manifold would lie.
37//!   The snapshot is still stored and still intersected, so a hand edit of ONE record (a
38//!   version's `requires`, say, in a [`crate::DirBackend`] file) cannot widen a run past
39//!   the other. Editing both is the host operator's act — the same trust as the ceiling
40//!   files — and the ceiling bounds every run whatever the records say.
41//! - **the host's ceiling** for this script ([`Ceiling`], from the host's
42//!   `<config home>/script-authority/{name}`): the host decides, never the script.
43//! - **the runner's own capability**: [`Capability::attenuate`] keeps a grant only when the
44//!   runner holds it, so a run never exceeds the intersection of runner and script.
45//!
46//! **Exclusions travel.** A publisher who holds `urn:cap:fs:read:/root` together with the
47//! exclusion `urn:cap:fs:read:-/root/secret` could not read the secret, so neither can the
48//! script, whoever runs it: every exclusion the publisher held is stored with the version's
49//! grant and handed to `attenuate`, which keeps a requested exclusion (it only narrows).
50//! The ceiling's exclusions travel the same way.
51//!
52//! ```
53//! use ikigai_core::Capability;
54//! use ikigai_script::authority::{effective, Ceiling};
55//! use std::collections::BTreeSet;
56//!
57//! let set = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
58//! let declared = set(&["urn:cap:lisp", "urn:cap:net:example.com", "urn:cap:mail:send"]);
59//! let granted = declared.clone(); // the publisher held all three
60//! let exclusions = set(&["urn:cap:net:-example.com/admin"]);
61//! // The host lets this script run Lisp and reach the network, but not send mail.
62//! let ceiling = Ceiling::scoped(["urn:cap:lisp", "urn:cap:net:*"]);
63//!
64//! let request = effective(&declared, &granted, &exclusions, &ceiling);
65//! assert_eq!(
66//!     request,
67//!     set(&["urn:cap:lisp", "urn:cap:net:-example.com/admin", "urn:cap:net:example.com"])
68//! );
69//!
70//! // A runner who holds Lisp and mail, but not the network: the run gets Lisp, and the
71//! // exclusion still rides along (it only narrows).
72//! let runner = Capability::scoped(["urn:cap:lisp", "urn:cap:mail:send", "urn:cap:script:run:x"]);
73//! let runs_as = runner.attenuate(request);
74//! assert!(runs_as.allows("urn:cap:lisp"));
75//! assert!(!runs_as.allows("urn:cap:mail:send")); // the ceiling withheld it
76//! assert!(!runs_as.allows("urn:cap:net:example.com")); // the runner never held it
77//! assert!(!runs_as.allows("urn:cap:script:run:x")); // nothing undeclared survives
78//! ```
79
80use std::collections::BTreeSet;
81use std::sync::Arc;
82
83use ikigai_core::{is_deny_scope, Capability, Error, Result};
84
85/// Reading a script, as declared: "holds some script read grant". A held grant names one
86/// script ([`cap_read`]) or a namespace ([`cap_namespace`]), checked inside by [`holds`].
87pub const CAP_READ: &str = "urn:cap:script:read:*";
88/// Publishing or replacing a script, as declared. Exact form: [`cap_write`].
89pub const CAP_WRITE: &str = "urn:cap:script:write:*";
90/// Retiring a script, as declared. Exact form: [`cap_delete`].
91pub const CAP_DELETE: &str = "urn:cap:script:delete:*";
92/// Running a script, as declared. Exact form: [`cap_run`], or [`CAP_RUN_PUBLIC`] for a
93/// public script.
94pub const CAP_RUN: &str = "urn:cap:script:run:*";
95/// Any script grant at all: the compiled form is readable by a reader OR a runner, and a
96/// `requires` is a conjunction, so it declares the family and checks the exact pair inside.
97pub const CAP_ANY: &str = "urn:cap:script:*";
98
99/// Run any script marked **public** (and published). The grant a host gives its anonymous
100/// principal: it is how "a public script grants the run capability to the anonymous
101/// principal" is spelled, since a host's anonymous grant cannot enumerate scripts that do
102/// not exist yet. Withhold it and nothing runs anonymously.
103pub const CAP_RUN_PUBLIC: &str = "urn:cap:script:run:public";
104/// Read the source of any script marked public (and published).
105pub const CAP_READ_PUBLIC: &str = "urn:cap:script:read:public";
106
107/// The capability `urn:lisp:eval` requires, and so every Lisp script declares implicitly
108/// (see [`crate::model::Language::capability`]). Spelled here rather than imported:
109/// the library does not link `ikigai-lisp`.
110pub const CAP_LISP: &str = "urn:cap:lisp";
111
112/// The exact grant to read `{name}`.
113pub fn cap_read(name: &str) -> String {
114    format!("urn:cap:script:read:{name}")
115}
116
117/// The exact grant to publish or replace `{name}`.
118pub fn cap_write(name: &str) -> String {
119    format!("urn:cap:script:write:{name}")
120}
121
122/// The exact grant to retire `{name}`.
123pub fn cap_delete(name: &str) -> String {
124    format!("urn:cap:script:delete:{name}")
125}
126
127/// The exact grant to run `{name}`.
128pub fn cap_run(name: &str) -> String {
129    format!("urn:cap:script:run:{name}")
130}
131
132/// What a script grant authorizes: the `{act}` in `urn:cap:script:{act}:{name}`.
133#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
134pub enum Act {
135    /// Read the source and head record without running it.
136    Read,
137    /// Publish, replace or draft it.
138    Write,
139    /// Retire it.
140    Delete,
141    /// Run it, as a read or for effects.
142    Run,
143}
144
145impl Act {
146    /// Every act.
147    pub const ALL: [Act; 4] = [Act::Read, Act::Write, Act::Delete, Act::Run];
148
149    /// The word in the scope.
150    pub fn as_str(self) -> &'static str {
151        match self {
152            Act::Read => "read",
153            Act::Write => "write",
154            Act::Delete => "delete",
155            Act::Run => "run",
156        }
157    }
158
159    /// `urn:cap:script:{act}:`, the prefix every grant of this act starts with.
160    fn prefix(self) -> String {
161        format!("urn:cap:script:{}:", self.as_str())
162    }
163}
164
165/// The exact grant to `act` on `{name}`: [`cap_read`], [`cap_write`], [`cap_delete`] or
166/// [`cap_run`].
167pub fn cap(act: Act, name: &str) -> String {
168    format!("{}{name}", act.prefix())
169}
170
171/// The grant to `act` on every script in a NAMESPACE: `urn:cap:script:{act}:{namespace}-*`
172/// (ledger #1174).
173///
174/// A namespace is a name prefix that ends at a `-`: the grant for `team` covers `team-report`
175/// and `team-a-report`, never `teammate` or `team` itself. The `{namespace}` is spelled like
176/// a script name (lowercase letters, digits, `-` and `_`, starting with a letter or digit),
177/// so it can be nested: `team-a` is a namespace inside `team`.
178///
179/// ★ **The parameter goes last, and the wildcard only at its end.** Core has no infix
180/// wildcard (`urn:cap:script:*:team` is not a form anything matches), and the act stays
181/// spelled out, so a namespace grant to RUN never publishes and a grant to READ never runs.
182/// There is no "every script" grant below root: `urn:cap:script:{act}:*` grants nothing,
183/// because it is spelled exactly like the FAMILY each act's door declares
184/// ([`CAP_WRITE`], …), the "holds some script grant" presence test, and a held string that
185/// means "everything" in one reading and "something" in the other is the confusion a grant
186/// must not carry.
187///
188/// An exclusion names a script (`urn:cap:script:{act}:-{name}`) or a namespace
189/// (`urn:cap:script:{act}:-{namespace}-*`) and always wins, over an exact grant too.
190///
191/// ```
192/// use ikigai_core::Capability;
193/// use ikigai_script::authority::{cap_namespace, holds, Act};
194///
195/// let grant = cap_namespace(Act::Write, "team").unwrap();
196/// assert_eq!(grant, "urn:cap:script:write:team-*");
197/// let alice = Capability::scoped([grant.as_str(), "urn:cap:script:write:-team-payroll"]);
198/// assert!(holds(&alice, Act::Write, "team-report"));
199/// assert!(holds(&alice, Act::Write, "team-a-report")); // nested
200/// assert!(!holds(&alice, Act::Write, "teammate")); // not in the namespace
201/// assert!(!holds(&alice, Act::Write, "team")); // the namespace is not a script
202/// assert!(!holds(&alice, Act::Write, "team-payroll")); // excluded
203/// assert!(!holds(&alice, Act::Run, "team-report")); // another act
204/// // Not a namespace: the wildcard must follow a `-`, and there is no "every script".
205/// assert!(cap_namespace(Act::Write, "").is_err());
206/// assert!(cap_namespace(Act::Write, "Team").is_err());
207/// assert!(!holds(&Capability::scoped(["urn:cap:script:write:team*"]), Act::Write, "teammate"));
208/// assert!(!holds(&Capability::scoped(["urn:cap:script:write:*"]), Act::Write, "anything"));
209/// ```
210pub fn cap_namespace(act: Act, namespace: &str) -> Result<String> {
211    if !is_namespace(namespace) {
212        return Err(Error::InvalidArgument {
213            name: "namespace".to_string(),
214            detail: format!(
215                "`{namespace}` is not a namespace: one or more `-`-separated words of lowercase \
216                 letters, digits, `-` and `_`, starting with a letter or digit, at most {} bytes",
217                crate::name::MAX_NAME - 1
218            ),
219        });
220    }
221    Ok(format!("{}{namespace}-*", act.prefix()))
222}
223
224/// Whether `namespace` is spelled like a script name (and leaves room for one more byte).
225fn is_namespace(namespace: &str) -> bool {
226    let mut chars = namespace.chars();
227    namespace.len() < crate::name::MAX_NAME
228        && chars
229            .next()
230            .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
231        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
232}
233
234/// Whether `capability` may `act` on the script `name`: it holds the exact grant
235/// ([`cap`]) or a namespace grant covering the name ([`cap_namespace`]), and no exclusion
236/// it holds names the script or a namespace it is in ([`excluded`]). Root holds every grant
237/// and no exclusion.
238///
239/// This is the whole rule, and every door of this crate checks it; a host or module asking
240/// "may this caller read script X?" (to filter what it shows, say) asks it here rather than
241/// with `Capability::allows`, which matches a held scope exactly and so would miss a
242/// namespace grant.
243pub fn holds(capability: &Capability, act: Act, name: &str) -> bool {
244    let Some(held) = capability.scopes() else {
245        return true;
246    };
247    if excluded(capability, act, name) {
248        return false;
249    }
250    if capability.allows(&cap(act, name)) {
251        return true;
252    }
253    let prefix = act.prefix();
254    held.iter()
255        .filter_map(|scope| scope.strip_prefix(prefix.as_str()))
256        .filter_map(|rule| rule.strip_suffix('*'))
257        .any(|covered| {
258            covered
259                .strip_suffix('-')
260                .is_some_and(|namespace| is_namespace(namespace) && name.starts_with(covered))
261        })
262}
263
264/// Whether an exclusion `capability` holds names the script `name` for `act`: the script
265/// itself (`urn:cap:script:{act}:-{name}`) or any prefix of its name
266/// (`urn:cap:script:{act}:-{prefix}*`).
267///
268/// Read liberally where a grant is read strictly: an exclusion only ever narrows, so any
269/// `-…*` it spells is honored as a prefix (`-*` excludes every script), while a grant must
270/// be a well-formed namespace to grant anything.
271pub fn excluded(capability: &Capability, act: Act, name: &str) -> bool {
272    let Some(held) = capability.scopes() else {
273        return false;
274    };
275    let prefix = act.prefix();
276    held.iter()
277        .filter_map(|scope| scope.strip_prefix(prefix.as_str()))
278        .filter_map(|rule| rule.strip_prefix('-'))
279        .any(|rule| match rule.strip_suffix('*') {
280            Some(covered) => name.starts_with(covered),
281            None => rule == name,
282        })
283}
284
285/// The run gate a script's OWN contract declares (a published query's or plan's
286/// `…:result` and `…:runs`), as the kernel's floor can check it before the endpoint runs.
287///
288/// The floor matches a declared scope exactly, or a declared `prefix*` as "holds some grant
289/// starting with `prefix`", and a description cannot depend on who asks. So a name with no
290/// `-` (which no namespace grant can cover) declares its exact run grant, and a name in a
291/// namespace declares its TOP-LEVEL namespace as a family: `team-a-report` declares
292/// `urn:cap:script:run:team-*`, which a holder of `run:team-a-report`, `run:team-a-*` or
293/// `run:team-*` satisfies. The exact rule ([`holds`]) is checked inside, so the cost is
294/// only that the script's entry is offered to a holder of another grant in the same
295/// top-level namespace, who is refused when they call it.
296///
297/// ```
298/// use ikigai_script::authority::run_floor;
299/// assert_eq!(run_floor("nightly"), "urn:cap:script:run:nightly");
300/// assert_eq!(run_floor("team-a-report"), "urn:cap:script:run:team-*");
301/// ```
302pub fn run_floor(name: &str) -> String {
303    match name.split_once('-') {
304        Some((top, _)) => format!("{}{top}-*", Act::Run.prefix()),
305        None => cap_run(name),
306    }
307}
308
309/// What is recorded as the principal when the request's capability names none: root (the
310/// host's own authority, which holds every principal and names none), a door that minted no
311/// `urn:cap:principal:` scope, or a capability carrying several (which names nobody). Honest
312/// rather than useful, so a host that never identifies its callers cannot have its records
313/// claim an identity nobody checked. The value records written before 0.2.0 carry for a host
314/// that stamped nothing, kept so old and new records agree.
315pub const UNSTAMPED: &str = "urn:script:principal:unstamped";
316
317/// What a door conventionally mints (`Capability::with_principal`) for a caller it cannot
318/// identify. Many callers share it, so it is nobody's identity: see [`is_identity`].
319pub const ANONYMOUS: &str = "urn:script:principal:anonymous";
320
321/// Who a request comes from: the principal its CAPABILITY names, minted by the host's door
322/// (`urn:cap:principal:<iri>`, ikigai-core 0.1.93), or [`UNSTAMPED`] when it names none.
323///
324/// The library records it on every publish and every run. It is never an argument: a caller
325/// cannot name itself, and narrowing a capability can never add or change a principal, so a
326/// sub-request issued on someone's behalf carries their name or none.
327///
328/// ```
329/// use ikigai_core::Capability;
330/// use ikigai_script::authority::{principal_of, UNSTAMPED};
331///
332/// let door = Capability::scoped(["urn:cap:script:run:job"]);
333/// let alice = door.with_principal("urn:example:person:alice").unwrap();
334/// assert_eq!(principal_of(&alice), "urn:example:person:alice");
335/// assert_eq!(principal_of(&door), UNSTAMPED);
336/// // Root is the host's own authority, not a party's.
337/// assert_eq!(principal_of(&Capability::root()), UNSTAMPED);
338/// ```
339pub fn principal_of(capability: &Capability) -> String {
340    capability.principal().unwrap_or(UNSTAMPED).to_string()
341}
342
343/// Whether a recorded principal names SOMEONE: anything but [`UNSTAMPED`] and
344/// [`ANONYMOUS`], which stand for many different callers at once.
345///
346/// It decides who may see a draft. A draft is visible only to its author until it is
347/// published, and the author is the principal recorded when the draft was written; two
348/// callers minted [`ANONYMOUS`] are not one person, and a capability naming no principal
349/// cannot be told apart from any other. So a draft written under either is nobody's: only
350/// root sees it until it is published. A door that cannot identify a caller mints
351/// [`ANONYMOUS`] (or nothing) rather than inventing a shared name.
352///
353/// ```
354/// use ikigai_script::authority::{is_identity, ANONYMOUS, UNSTAMPED};
355/// assert!(is_identity("urn:example:person:brian"));
356/// assert!(!is_identity(ANONYMOUS));
357/// assert!(!is_identity(UNSTAMPED));
358/// assert!(!is_identity(""));
359/// ```
360pub fn is_identity(principal: &str) -> bool {
361    !principal.trim().is_empty() && principal != UNSTAMPED && principal != ANONYMOUS
362}
363
364/// The host's ceiling for one script: the most it will let that script's runs touch.
365#[derive(Clone, Debug, PartialEq, Eq)]
366pub enum Ceiling {
367    /// The host imposes nothing beyond what the script declared and its publisher held.
368    Unbounded,
369    /// Exactly these scopes. A grant ending in `*` admits every scope under that prefix
370    /// (`urn:cap:net:*`); a deny-shaped scope ([`is_deny_scope`]) is an exclusion every run
371    /// of the script carries.
372    Scopes(BTreeSet<String>),
373}
374
375impl Ceiling {
376    /// No ceiling: [`Ceiling::Unbounded`].
377    pub fn unbounded() -> Ceiling {
378        Ceiling::Unbounded
379    }
380
381    /// The empty ceiling: the script may touch nothing, not even its language, so it cannot
382    /// run. The answer for a script the host has no file for, when the host fails closed.
383    pub fn nothing() -> Ceiling {
384        Ceiling::Scopes(BTreeSet::new())
385    }
386
387    /// Exactly these scopes.
388    pub fn scoped<I, S>(scopes: I) -> Ceiling
389    where
390        I: IntoIterator<Item = S>,
391        S: Into<String>,
392    {
393        Ceiling::Scopes(scopes.into_iter().map(Into::into).collect())
394    }
395
396    /// Parse the text of a ceiling file (`<config home>/script-authority/{name}`): one
397    /// `urn:cap:` scope per line, `#` to the end of a line is a comment, blank lines are
398    /// ignored. A lone `*` line means [`Ceiling::Unbounded`].
399    ///
400    /// ```
401    /// use ikigai_script::authority::Ceiling;
402    /// let ceiling = Ceiling::parse(
403    ///     "# the nightly report\nurn:cap:lisp\nurn:cap:net:*   # any host\n\
404    ///      urn:cap:net:-internal.example\n",
405    /// )
406    /// .unwrap();
407    /// assert!(ceiling.allows("urn:cap:lisp"));
408    /// assert!(ceiling.allows("urn:cap:net:example.com"));
409    /// assert!(!ceiling.allows("urn:cap:mail:send"));
410    /// assert_eq!(ceiling.exclusions(), vec!["urn:cap:net:-internal.example".to_string()]);
411    /// assert_eq!(Ceiling::parse("*\n").unwrap(), Ceiling::Unbounded);
412    /// assert!(Ceiling::parse("lisp\n").is_err()); // not a capability scope
413    /// ```
414    pub fn parse(text: &str) -> Result<Ceiling> {
415        let mut scopes = BTreeSet::new();
416        for (number, line) in text.lines().enumerate() {
417            let line = line.split('#').next().unwrap_or("").trim();
418            if line.is_empty() {
419                continue;
420            }
421            if line == "*" {
422                return Ok(Ceiling::Unbounded);
423            }
424            if !line.starts_with("urn:cap:") || line.split_whitespace().count() != 1 {
425                return Err(Error::InvalidArgument {
426                    name: "ceiling".to_string(),
427                    detail: format!(
428                        "line {}: `{line}` is not one `urn:cap:` scope (or `*` for no ceiling)",
429                        number + 1
430                    ),
431                });
432            }
433            scopes.insert(line.to_string());
434        }
435        Ok(Ceiling::Scopes(scopes))
436    }
437
438    /// Whether the ceiling admits the grant `scope`: held exactly, or under a held
439    /// `prefix*` family. An exclusion is never admitted as a grant.
440    pub fn allows(&self, scope: &str) -> bool {
441        match self {
442            Ceiling::Unbounded => !is_deny_scope(scope),
443            Ceiling::Scopes(held) => {
444                !is_deny_scope(scope)
445                    && held.iter().any(|h| match h.strip_suffix('*') {
446                        Some(prefix) => scope.starts_with(prefix),
447                        None => h == scope,
448                    })
449            }
450        }
451    }
452
453    /// The exclusions the ceiling carries, which every run of the script carries too.
454    pub fn exclusions(&self) -> Vec<String> {
455        match self {
456            Ceiling::Unbounded => Vec::new(),
457            Ceiling::Scopes(held) => held.iter().filter(|s| is_deny_scope(s)).cloned().collect(),
458        }
459    }
460}
461
462/// How the host answers "what is the ceiling for this script?" — called on every run, so a
463/// changed ceiling takes effect at the next one (and a cached result is cut by
464/// [`crate::name::authority_thread`]).
465///
466/// ```
467/// use ikigai_script::authority::{Ceiling, CeilingPolicy};
468/// use std::sync::Arc;
469/// // A host reading `<config home>/script-authority/{name}`, failing closed when the file
470/// // is missing or unreadable:
471/// let dir = std::path::PathBuf::from("/etc/my-host/script-authority");
472/// let policy: CeilingPolicy = Arc::new(move |name| {
473///     std::fs::read_to_string(dir.join(name))
474///         .ok()
475///         .and_then(|text| Ceiling::parse(&text).ok())
476///         .unwrap_or_else(Ceiling::nothing)
477/// });
478/// # let _ = policy;
479/// ```
480pub type CeilingPolicy = Arc<dyn Fn(&str) -> Ceiling + Send + Sync>;
481
482/// A ceiling policy that answers the same ceiling for every script.
483pub fn same_for_all(ceiling: Ceiling) -> CeilingPolicy {
484    Arc::new(move |_| ceiling.clone())
485}
486
487/// Parse `requires=`: whitespace-separated `urn:cap:` grants.
488///
489/// Refused: anything not under `urn:cap:`, a family wildcard (`*`: a script states the
490/// grants it needs, and attenuation keeps grants by exact name, so a wildcard would keep
491/// nothing), and an exclusion (it is not something a script can need; exclusions come
492/// from the publisher and the host).
493pub fn parse_requires(text: &str) -> Result<BTreeSet<String>> {
494    let mut scopes = BTreeSet::new();
495    for scope in text.split_whitespace() {
496        let detail = if !scope.starts_with("urn:cap:") {
497            Some("is not a `urn:cap:` scope")
498        } else if scope.ends_with('*') {
499            Some(
500                "is a family wildcard; a script declares the exact grants it needs, \
501                 because a run keeps grants by exact name",
502            )
503        } else if is_deny_scope(scope) {
504            Some("is an exclusion; a script declares what it needs, not what it may not touch")
505        } else {
506            None
507        };
508        if let Some(detail) = detail {
509            return Err(Error::InvalidArgument {
510                name: "requires".to_string(),
511                detail: format!("`{scope}` {detail}"),
512            });
513        }
514        scopes.insert(scope.to_string());
515    }
516    Ok(scopes)
517}
518
519/// What the publisher held of a script's declared scopes, taken at publish time.
520#[derive(Clone, Debug, PartialEq, Eq)]
521pub struct Grant {
522    /// The declared scopes the publisher held: all of them, since a publish declaring more
523    /// is refused. For a declared FAMILY (`prefix*`, which only a SPARQL script's derived
524    /// authority carries: [`crate::sparql::CAP_READ_GRAPH`]), the publisher's own grants
525    /// under it, each one, or the family itself when the publisher was root.
526    pub granted: BTreeSet<String>,
527    /// Every exclusion the publisher's capability carried, which every run carries too.
528    pub exclusions: BTreeSet<String>,
529}
530
531/// A declared family (`prefix*`): its prefix.
532fn family(scope: &str) -> Option<&str> {
533    scope.strip_suffix('*')
534}
535
536/// Take the publisher's grant for `declared`, or refuse with a typed `Denied` naming what
537/// they do not hold. **No elevation**: a script never runs with more than its publisher
538/// held.
539///
540/// A declared family (`prefix*`) is held when the publisher holds some grant under it,
541/// the kernel's own reading of a family in a `requires`; what is taken is every such
542/// grant, so a run's union is never wider than its publisher's.
543pub fn grant_at_publish(publisher: &Capability, declared: &BTreeSet<String>) -> Result<Grant> {
544    let held_under = |prefix: &str| -> Vec<String> {
545        publisher
546            .scopes()
547            .map(|held| {
548                held.iter()
549                    .filter(|s| s.starts_with(prefix) && !is_deny_scope(s))
550                    .cloned()
551                    .collect()
552            })
553            .unwrap_or_default()
554    };
555    let mut granted = BTreeSet::new();
556    let mut missing: Vec<&str> = Vec::new();
557    for scope in declared {
558        match family(scope) {
559            Some(_) if publisher.is_root() => {
560                granted.insert(scope.clone());
561            }
562            Some(prefix) => {
563                let held = held_under(prefix);
564                if held.is_empty() {
565                    missing.push(scope);
566                }
567                granted.extend(held);
568            }
569            None if publisher.allows(scope) => {
570                granted.insert(scope.clone());
571            }
572            None => missing.push(scope),
573        }
574    }
575    if !missing.is_empty() {
576        return Err(Error::Denied(format!(
577            "a script cannot be given more than its publisher holds, and this capability \
578             does not hold {} — declare less, or publish under a capability that holds it",
579            missing.join(", ")
580        )));
581    }
582    let exclusions = publisher
583        .scopes()
584        .map(|held| held.iter().filter(|s| is_deny_scope(s)).cloned().collect())
585        .unwrap_or_default();
586    Ok(Grant {
587        granted,
588        exclusions,
589    })
590}
591
592/// The scopes a run asks to keep: `{ s in declared : s in granted and ceiling.allows(s) }`
593/// plus the publisher's and the ceiling's exclusions. The run's capability is the runner's
594/// own [`Capability::attenuate`]d to this set — see the module documentation.
595///
596/// A declared family keeps the publisher's grants under it that the ceiling allows; when
597/// the publisher was root it keeps the family itself, a marker the SPARQL run expands
598/// into exact grants (each checked against the ceiling and the runner) before anything
599/// is attenuated, because attenuation keeps grants by exact name.
600pub fn effective(
601    declared: &BTreeSet<String>,
602    granted: &BTreeSet<String>,
603    exclusions: &BTreeSet<String>,
604    ceiling: &Ceiling,
605) -> BTreeSet<String> {
606    let mut keep: BTreeSet<String> = BTreeSet::new();
607    for scope in declared {
608        match family(scope) {
609            Some(_) if granted.contains(scope) => {
610                keep.insert(scope.clone());
611            }
612            Some(prefix) => keep.extend(
613                granted
614                    .iter()
615                    .filter(|g| g.starts_with(prefix) && ceiling.allows(g))
616                    .cloned(),
617            ),
618            None if granted.contains(scope) && ceiling.allows(scope) => {
619                keep.insert(scope.clone());
620            }
621            None => {}
622        }
623    }
624    keep.extend(exclusions.iter().filter(|s| is_deny_scope(s)).cloned());
625    keep.extend(ceiling.exclusions());
626    keep
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    fn set(xs: &[&str]) -> BTreeSet<String> {
634        xs.iter().map(|s| s.to_string()).collect()
635    }
636
637    #[test]
638    fn a_publisher_cannot_declare_what_they_do_not_hold() {
639        let publisher = Capability::scoped(["urn:cap:lisp", "urn:cap:script:write:x"]);
640        let err =
641            grant_at_publish(&publisher, &set(&["urn:cap:lisp", "urn:cap:mail:send"])).unwrap_err();
642        assert!(
643            matches!(&err, Error::Denied(m) if m.contains("urn:cap:mail:send") && !m.contains("urn:cap:lisp,")),
644            "{err:?}"
645        );
646    }
647
648    #[test]
649    fn the_publishers_exclusions_are_taken_with_the_grant() {
650        let publisher = Capability::scoped([
651            "urn:cap:lisp",
652            "urn:cap:fs:read:/root",
653            "urn:cap:fs:read:-/root/secret",
654        ]);
655        let grant =
656            grant_at_publish(&publisher, &set(&["urn:cap:lisp", "urn:cap:fs:read:/root"])).unwrap();
657        assert_eq!(grant.exclusions, set(&["urn:cap:fs:read:-/root/secret"]));
658        // Root holds no exclusions and every grant.
659        let grant = grant_at_publish(&Capability::root(), &set(&["urn:cap:anything"])).unwrap();
660        assert!(grant.exclusions.is_empty());
661    }
662
663    #[test]
664    fn a_hand_widened_grant_still_cannot_exceed_the_stored_snapshot() {
665        // declared names mail, but the snapshot (what the publisher held) does not.
666        let request = effective(
667            &set(&["urn:cap:lisp", "urn:cap:mail:send"]),
668            &set(&["urn:cap:lisp"]),
669            &BTreeSet::new(),
670            &Ceiling::Unbounded,
671        );
672        assert_eq!(request, set(&["urn:cap:lisp"]));
673    }
674
675    #[test]
676    fn a_family_takes_the_publishers_grants_under_it() {
677        let family = "urn:cap:store:read:graph:*";
678        let publisher = Capability::scoped([
679            "urn:cap:store:read:graph:urn:a",
680            "urn:cap:store:read:graph:urn:b",
681            "urn:cap:other",
682        ]);
683        let grant = grant_at_publish(&publisher, &set(&[family])).unwrap();
684        assert_eq!(
685            grant.granted,
686            set(&[
687                "urn:cap:store:read:graph:urn:a",
688                "urn:cap:store:read:graph:urn:b"
689            ])
690        );
691        // Holding nothing under it is holding nothing it declares.
692        assert!(grant_at_publish(&Capability::scoped(["urn:cap:other"]), &set(&[family])).is_err());
693        // Root keeps the family, as a marker for the run to expand.
694        let root = grant_at_publish(&Capability::root(), &set(&[family])).unwrap();
695        assert_eq!(root.granted, set(&[family]));
696        // The ceiling narrows the members.
697        let keep = effective(
698            &set(&[family]),
699            &grant.granted,
700            &BTreeSet::new(),
701            &Ceiling::scoped(["urn:cap:store:read:graph:urn:a"]),
702        );
703        assert_eq!(keep, set(&["urn:cap:store:read:graph:urn:a"]));
704    }
705
706    #[test]
707    fn requires_refuses_wildcards_exclusions_and_strangers() {
708        assert!(parse_requires("urn:cap:net:*").is_err());
709        assert!(parse_requires("urn:cap:fs:read:-/x").is_err());
710        assert!(parse_requires("lisp").is_err());
711        assert_eq!(
712            parse_requires(" urn:cap:a\n urn:cap:b urn:cap:a ").unwrap(),
713            set(&["urn:cap:a", "urn:cap:b"])
714        );
715    }
716
717    #[test]
718    fn a_ceiling_never_admits_an_exclusion_as_a_grant() {
719        assert!(!Ceiling::Unbounded.allows("urn:cap:net:-x"));
720        assert!(!Ceiling::scoped(["urn:cap:net:*"]).allows("urn:cap:net:-x"));
721        assert!(!Ceiling::nothing().allows("urn:cap:lisp"));
722    }
723}