Skip to main content

boundgraph_core/
grant.rs

1//! Permission atoms and sets, as data.
2//!
3//! This module owns the *shape* and the *syntax* of a permission. The algebra
4//! — containment (`⊑`), meet and join, normalisation, disjointness — lives in
5//! `boundgraph-grant` (feature 02). Keeping the type here lets
6//! [`WorkOrder`](crate::WorkOrder) and the ledger reference permissions without
7//! depending on the solver.
8//!
9//! Two consequences of Law I are already visible in these types:
10//!
11//! - A [`GrantSet`] is a `BTreeSet`, so it has no insertion order and no
12//!   duplicates. Two sets with the same atoms are the same value and share a
13//!   digest.
14//! - [`GrantSet::EMPTY`] is the default. A step that declares nothing gets
15//!   nothing; there is no representation of "unrestricted".
16
17use std::collections::BTreeSet;
18use std::fmt;
19use std::str::FromStr;
20
21use serde::{Deserialize, Serialize};
22
23/// The resource families the specification documents.
24///
25/// Parsing does **not** reject an unknown domain — the vocabulary is meant to
26/// grow, and a hard-coded list in the parser would make every new sandbox
27/// capability a breaking change. Unknown domains are instead treated
28/// conservatively by the solver, and [`GrantAtom::has_known_domain`] lets a
29/// diagnostic point out a likely typo.
30pub const KNOWN_DOMAINS: &[&str] = &["fs", "net", "proc", "env", "secret", "time", "rand"];
31
32/// One permission, as `domain:action:resource`.
33///
34/// For example `fs:read:/src/**`, `net:egress:api.example.com:443`, or
35/// `secret:use:stripe_key`. The resource may be omitted for permissions that
36/// take no argument, giving the two-part form `time:now` or `rand:bytes`.
37///
38/// A resource may be a *template* such as `{path}`, resolved at runtime and
39/// clamped to the statically computed envelope, so a dynamic value can only
40/// ever narrow the grant, never widen it.
41#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
42pub struct GrantAtom {
43    /// The resource family: `fs`, `net`, `proc`, `env`, `secret`, `time`, `rand`.
44    pub domain: String,
45    /// What may be done: `read`, `write`, `egress`, `exec`, `use`.
46    pub action: String,
47    /// What it applies to. May contain globs or `{template}` segments, and may
48    /// be empty for permissions that take no argument.
49    pub resource: String,
50}
51
52impl GrantAtom {
53    /// Build an atom from its three parts.
54    pub fn new(
55        domain: impl Into<String>,
56        action: impl Into<String>,
57        resource: impl Into<String>,
58    ) -> Self {
59        Self {
60            domain: domain.into(),
61            action: action.into(),
62            resource: resource.into(),
63        }
64    }
65
66    /// Whether the resource contains a `{template}` segment to be resolved at
67    /// runtime.
68    ///
69    /// Templates stay symbolic through grant inference; the compiler reasons
70    /// about the envelope they are clamped to rather than the value.
71    pub fn is_template(&self) -> bool {
72        self.resource.contains('{') && self.resource.contains('}')
73    }
74
75    /// Whether this permission takes no resource argument, as `time:now` does.
76    pub fn is_bare(&self) -> bool {
77        self.resource.is_empty()
78    }
79
80    /// Whether the domain is one the specification documents.
81    ///
82    /// A `false` here is not an error — see [`KNOWN_DOMAINS`].
83    pub fn has_known_domain(&self) -> bool {
84        KNOWN_DOMAINS.contains(&self.domain.as_str())
85    }
86}
87
88/// An atom did not match `domain:action[:resource]`.
89#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
90pub enum GrantAtomParseError {
91    /// The input was empty or only whitespace.
92    #[error("a grant atom cannot be empty")]
93    Empty,
94
95    /// Only one segment was supplied.
96    #[error("expected `domain:action[:resource]`, found {input:?} with no action")]
97    MissingAction {
98        /// What was supplied.
99        input: String,
100    },
101
102    /// The domain or action segment was empty.
103    #[error("the {segment} segment of a grant atom cannot be empty")]
104    EmptySegment {
105        /// Which segment: `domain` or `action`.
106        segment: &'static str,
107    },
108
109    /// The domain or action contained whitespace.
110    #[error("the {segment} segment must not contain whitespace, found {ch:?}")]
111    Whitespace {
112        /// Which segment: `domain` or `action`.
113        segment: &'static str,
114        /// The offending character.
115        ch: char,
116    },
117}
118
119impl FromStr for GrantAtom {
120    type Err = GrantAtomParseError;
121
122    /// Parse `domain:action[:resource]`.
123    ///
124    /// Splits on the **first two** colons only, so a resource may itself
125    /// contain colons — `net:egress:api.example.com:443` is one atom whose
126    /// resource is `api.example.com:443`, not a malformed four-part atom.
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        if s.trim().is_empty() {
129            return Err(GrantAtomParseError::Empty);
130        }
131
132        let mut parts = s.splitn(3, ':');
133        let domain = parts.next().unwrap_or_default();
134        let Some(action) = parts.next() else {
135            return Err(GrantAtomParseError::MissingAction {
136                input: s.to_string(),
137            });
138        };
139        // Absent third segment is the bare form; an empty one is equivalent.
140        let resource = parts.next().unwrap_or("");
141
142        check_segment(domain, "domain")?;
143        check_segment(action, "action")?;
144
145        Ok(Self::new(domain, action, resource))
146    }
147}
148
149/// The domain and action are identifiers, not free text: they must be present
150/// and must not contain whitespace. The resource is deliberately unconstrained
151/// — real paths contain spaces.
152fn check_segment(value: &str, segment: &'static str) -> Result<(), GrantAtomParseError> {
153    if value.is_empty() {
154        return Err(GrantAtomParseError::EmptySegment { segment });
155    }
156    if let Some(ch) = value.chars().find(|c| c.is_whitespace()) {
157        return Err(GrantAtomParseError::Whitespace { segment, ch });
158    }
159    Ok(())
160}
161
162impl fmt::Display for GrantAtom {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        if self.is_bare() {
165            write!(f, "{}:{}", self.domain, self.action)
166        } else {
167            write!(f, "{}:{}:{}", self.domain, self.action, self.resource)
168        }
169    }
170}
171
172impl fmt::Debug for GrantAtom {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(f, "{self}")
175    }
176}
177
178/// A set of permissions.
179///
180/// Unordered and duplicate-free by construction, so equal permissions always
181/// produce an equal digest regardless of how the set was built.
182#[derive(Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
183#[serde(transparent)]
184pub struct GrantSet(BTreeSet<GrantAtom>);
185
186impl GrantSet {
187    /// No permissions at all. The default, and the floor of the lattice.
188    pub const EMPTY: Self = Self(BTreeSet::new());
189
190    /// Build a set from atoms, discarding duplicates.
191    pub fn new(atoms: impl IntoIterator<Item = GrantAtom>) -> Self {
192        Self(atoms.into_iter().collect())
193    }
194
195    /// Parse a set from atom strings.
196    ///
197    /// # Errors
198    ///
199    /// Returns the first [`GrantAtomParseError`] encountered.
200    pub fn parse<'a>(
201        atoms: impl IntoIterator<Item = &'a str>,
202    ) -> Result<Self, GrantAtomParseError> {
203        atoms
204            .into_iter()
205            .map(GrantAtom::from_str)
206            .collect::<Result<BTreeSet<_>, _>>()
207            .map(Self)
208    }
209
210    /// Whether this grants nothing.
211    pub fn is_empty(&self) -> bool {
212        self.0.is_empty()
213    }
214
215    /// How many distinct atoms this holds.
216    pub fn len(&self) -> usize {
217        self.0.len()
218    }
219
220    /// Iterate the atoms in a deterministic order.
221    pub fn iter(&self) -> impl Iterator<Item = &GrantAtom> {
222        self.0.iter()
223    }
224
225    /// Whether this set contains exactly this atom.
226    ///
227    /// This is literal membership, **not** the containment relation `⊑` — an
228    /// atom may be permitted by a broader atom in the set without appearing in
229    /// it. That question belongs to the grant lattice.
230    pub fn contains_atom(&self, atom: &GrantAtom) -> bool {
231        self.0.contains(atom)
232    }
233
234    /// Whether any atom carries an unresolved template.
235    pub fn has_templates(&self) -> bool {
236        self.0.iter().any(GrantAtom::is_template)
237    }
238}
239
240impl FromIterator<GrantAtom> for GrantSet {
241    fn from_iter<I: IntoIterator<Item = GrantAtom>>(iter: I) -> Self {
242        Self::new(iter)
243    }
244}
245
246impl<'a> IntoIterator for &'a GrantSet {
247    type Item = &'a GrantAtom;
248    type IntoIter = std::collections::btree_set::Iter<'a, GrantAtom>;
249
250    fn into_iter(self) -> Self::IntoIter {
251        self.0.iter()
252    }
253}
254
255impl fmt::Debug for GrantSet {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        f.debug_set().entries(self.0.iter()).finish()
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::canonical::Canonical;
265
266    fn atoms() -> Vec<GrantAtom> {
267        vec![
268            GrantAtom::new("fs", "read", "/src/**"),
269            GrantAtom::new("net", "egress", "api.example.com:443"),
270        ]
271    }
272
273    #[test]
274    fn default_grants_nothing() {
275        assert!(GrantSet::default().is_empty());
276        assert!(GrantSet::EMPTY.is_empty());
277    }
278
279    #[test]
280    fn insertion_order_does_not_change_identity() {
281        let forward = GrantSet::new(atoms());
282        let reverse = GrantSet::new(atoms().into_iter().rev());
283        assert_eq!(forward, reverse);
284        assert_eq!(forward.digest().unwrap(), reverse.digest().unwrap());
285    }
286
287    #[test]
288    fn duplicates_collapse() {
289        let mut with_dupes = atoms();
290        with_dupes.extend(atoms());
291        assert_eq!(GrantSet::new(with_dupes).len(), 2);
292    }
293
294    #[test]
295    fn templates_are_detected() {
296        assert!(GrantAtom::new("fs", "read", "{path}").is_template());
297        assert!(!GrantAtom::new("fs", "read", "/src/**").is_template());
298        assert!(GrantSet::new([GrantAtom::new("fs", "read", "{path}")]).has_templates());
299        assert!(!GrantSet::new(atoms()).has_templates());
300    }
301
302    // ── US-0201: syntax ─────────────────────────────────────────────────────
303
304    #[test]
305    fn parses_the_documented_three_part_form() {
306        let a: GrantAtom = "fs:read:/src/**".parse().unwrap();
307        assert_eq!(a, GrantAtom::new("fs", "read", "/src/**"));
308    }
309
310    #[test]
311    fn a_resource_may_itself_contain_colons() {
312        // The classic trap: this is three segments, not four.
313        let a: GrantAtom = "net:egress:api.example.com:443".parse().unwrap();
314        assert_eq!(a.domain, "net");
315        assert_eq!(a.action, "egress");
316        assert_eq!(a.resource, "api.example.com:443");
317    }
318
319    #[test]
320    fn parses_the_bare_two_part_form() {
321        for s in ["time:now", "rand:bytes"] {
322            let a: GrantAtom = s.parse().unwrap();
323            assert!(a.is_bare(), "{s} should have no resource");
324            assert_eq!(
325                a.to_string(),
326                s,
327                "bare atoms round-trip without a trailing colon"
328            );
329        }
330        // A trailing colon is the same thing written differently.
331        assert_eq!(
332            "time:now:".parse::<GrantAtom>().unwrap(),
333            "time:now".parse::<GrantAtom>().unwrap()
334        );
335    }
336
337    #[test]
338    fn every_documented_domain_parses() {
339        let documented = [
340            "fs:read:/src/**",
341            "fs:write:/build/**",
342            "net:egress:api.example.com:443",
343            "proc:exec:git",
344            "env:read:HOME",
345            "secret:use:stripe_key",
346            "time:now",
347            "rand:bytes",
348        ];
349        for s in documented {
350            let a: GrantAtom = s.parse().unwrap_or_else(|e| panic!("{s} failed: {e}"));
351            assert!(a.has_known_domain(), "{s} should use a documented domain");
352        }
353    }
354
355    #[test]
356    fn display_round_trips_through_parse() {
357        for s in [
358            "fs:read:/src/**",
359            "net:egress:api.example.com:443",
360            "fs:read:{path}",
361            "time:now",
362            "fs:read:/a path/with spaces/**",
363        ] {
364            let a: GrantAtom = s.parse().unwrap();
365            assert_eq!(a.to_string().parse::<GrantAtom>().unwrap(), a);
366        }
367    }
368
369    #[test]
370    fn glob_and_template_resources_are_preserved_verbatim() {
371        assert_eq!(
372            "fs:read:/src/**".parse::<GrantAtom>().unwrap().resource,
373            "/src/**"
374        );
375        assert_eq!(
376            "fs:read:/src/*".parse::<GrantAtom>().unwrap().resource,
377            "/src/*"
378        );
379
380        let t: GrantAtom = "fs:read:{path}".parse().unwrap();
381        assert_eq!(t.resource, "{path}");
382        assert!(t.is_template(), "a parsed template is marked symbolic");
383    }
384
385    #[test]
386    fn an_unknown_domain_parses_but_is_flagged() {
387        let a: GrantAtom = "gpu:use:cuda0".parse().unwrap();
388        assert!(!a.has_known_domain());
389    }
390
391    #[test]
392    fn malformed_atoms_name_the_offending_segment() {
393        assert_eq!("".parse::<GrantAtom>(), Err(GrantAtomParseError::Empty));
394        assert_eq!("   ".parse::<GrantAtom>(), Err(GrantAtomParseError::Empty));
395
396        assert!(matches!(
397            "fsread".parse::<GrantAtom>(),
398            Err(GrantAtomParseError::MissingAction { .. })
399        ));
400        assert_eq!(
401            ":read:/src".parse::<GrantAtom>(),
402            Err(GrantAtomParseError::EmptySegment { segment: "domain" })
403        );
404        assert_eq!(
405            "fs::/src".parse::<GrantAtom>(),
406            Err(GrantAtomParseError::EmptySegment { segment: "action" })
407        );
408        assert_eq!(
409            "f s:read:/src".parse::<GrantAtom>(),
410            Err(GrantAtomParseError::Whitespace {
411                segment: "domain",
412                ch: ' '
413            })
414        );
415    }
416
417    #[test]
418    fn a_set_parses_from_strings() {
419        let g = GrantSet::parse(["fs:read:/src/**", "net:egress:api.example.com:443"]).unwrap();
420        assert_eq!(g.len(), 2);
421        assert!(GrantSet::parse(["fs:read:/src/**", "bad"]).is_err());
422    }
423}