Skip to main content

boundgraph_grant/
pattern.rs

1//! Resource patterns, and what one pattern says about another.
2//!
3//! A grant's resource is a pattern over concrete resources: `/src/**` stands
4//! for every path under `/src`. Deciding Law I means deciding questions about
5//! those *sets* — does this pattern cover that one, could these two ever touch
6//! the same thing — without enumerating them.
7//!
8//! # The decidable fragment
9//!
10//! A richer pattern language is easy to write and impossible to decide: two
11//! regex-ish patterns can overlap in ways no solver settles cheaply. Goal G5
12//! says conservative by default, so this module supports a fragment it can
13//! decide *exactly*, and answers [`Tri::Unknown`] for everything else. The
14//! compiler turns `Unknown` into a rejection (US-0206), so an undecidable
15//! pattern costs you a build error, never a false guarantee.
16//!
17//! Inside the fragment:
18//!
19//! | Form | Meaning |
20//! |---|---|
21//! | `src` | a literal segment |
22//! | `*` | exactly one segment, as a whole |
23//! | `**` | zero or more segments, **final position only** |
24//!
25//! Outside it, and therefore `Unknown`: `**` anywhere but last, partial-segment
26//! globs like `*.rs`, and `{template}` segments, which are symbolic until the
27//! runtime clamps them.
28
29use std::fmt;
30
31/// A three-valued answer.
32///
33/// [`Tri::Unknown`] means "not decided", and every consumer must treat it as a
34/// refusal rather than a maybe-yes. There is deliberately no conversion to
35/// `bool` that guesses.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum Tri {
38    /// Definitely true.
39    Yes,
40    /// Definitely false.
41    No,
42    /// Could not be decided from the pattern language.
43    Unknown,
44}
45
46impl Tri {
47    /// Whether this is a definite yes. `Unknown` is not.
48    pub fn is_yes(self) -> bool {
49        self == Self::Yes
50    }
51
52    /// Whether this is a definite no. `Unknown` is not.
53    pub fn is_no(self) -> bool {
54        self == Self::No
55    }
56
57    /// Whether the answer is undecided.
58    pub fn is_unknown(self) -> bool {
59        self == Self::Unknown
60    }
61
62    /// Conjunction: `No` dominates, then `Unknown`.
63    ///
64    /// A definite `No` survives an undecided sibling — if one conjunct is
65    /// already false the whole thing is false regardless.
66    pub fn and(self, other: Self) -> Self {
67        match (self, other) {
68            (Self::No, _) | (_, Self::No) => Self::No,
69            (Self::Unknown, _) | (_, Self::Unknown) => Self::Unknown,
70            _ => Self::Yes,
71        }
72    }
73
74    /// Disjunction: `Yes` dominates, then `Unknown`.
75    pub fn or(self, other: Self) -> Self {
76        match (self, other) {
77            (Self::Yes, _) | (_, Self::Yes) => Self::Yes,
78            (Self::Unknown, _) | (_, Self::Unknown) => Self::Unknown,
79            _ => Self::No,
80        }
81    }
82
83    /// Fold a sequence with [`Tri::and`], starting from `Yes`.
84    pub fn all(items: impl IntoIterator<Item = Self>) -> Self {
85        items.into_iter().fold(Self::Yes, Self::and)
86    }
87
88    /// Fold a sequence with [`Tri::or`], starting from `No`.
89    pub fn any(items: impl IntoIterator<Item = Self>) -> Self {
90        items.into_iter().fold(Self::No, Self::or)
91    }
92}
93
94impl fmt::Display for Tri {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(match self {
97            Self::Yes => "yes",
98            Self::No => "no",
99            Self::Unknown => "undecidable",
100        })
101    }
102}
103
104/// One segment of a resource pattern.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub(crate) enum Seg {
107    /// An exact segment.
108    Literal(String),
109    /// `*` — exactly one segment.
110    Single,
111    /// `**` — zero or more segments. Only valid in final position.
112    Recursive,
113    /// Something outside the decidable fragment.
114    ///
115    /// Carries the original text. That is load-bearing, not diagnostic: if
116    /// every opaque segment compared equal, `/src/*.rs` and `/src/*.py` would
117    /// parse to identical patterns and the equality short-circuit in [`covers`]
118    /// would report that each contains the other — a false guarantee, which is
119    /// the one failure mode this module exists to prevent.
120    Opaque(String),
121}
122
123/// A parsed resource pattern.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Pattern {
126    segments: Vec<Seg>,
127    decidable: bool,
128}
129
130impl Pattern {
131    /// Parse a resource string into a pattern.
132    ///
133    /// Never fails: anything outside the decidable fragment is recorded as
134    /// such and surfaces later as [`Tri::Unknown`].
135    pub fn parse(resource: &str) -> Self {
136        // A bare atom (`time:now`) has no resource and exactly one member: itself.
137        if resource.is_empty() {
138            return Self {
139                segments: Vec::new(),
140                decidable: true,
141            };
142        }
143
144        // `/src/` and `/src` denote the same set. Normalise the trailing
145        // separator away so the order is antisymmetric over denotations.
146        let trimmed = resource.strip_suffix('/').unwrap_or(resource);
147        let raw: Vec<&str> = trimmed.split('/').collect();
148        let last = raw.len().saturating_sub(1);
149
150        let mut decidable = true;
151        let segments = raw
152            .into_iter()
153            .enumerate()
154            .map(|(i, s)| match s {
155                "*" => Seg::Single,
156                // `**` in the middle makes containment undecidable cheaply:
157                // it can absorb a variable number of segments on both sides.
158                "**" if i == last => Seg::Recursive,
159                "**" => {
160                    decidable = false;
161                    Seg::Opaque(s.to_string())
162                }
163                other if other.contains('{') || other.contains('}') => {
164                    decidable = false;
165                    Seg::Opaque(other.to_string())
166                }
167                other if other.contains('*') => {
168                    // A partial-segment glob such as `*.rs`.
169                    decidable = false;
170                    Seg::Opaque(other.to_string())
171                }
172                other => Seg::Literal(other.to_string()),
173            })
174            .collect();
175
176        Self {
177            segments,
178            decidable,
179        }
180    }
181
182    /// The parsed segments, for the prefix trie to index.
183    pub(crate) fn segments(&self) -> &[Seg] {
184        &self.segments
185    }
186
187    /// Whether this pattern lies inside the decidable fragment.
188    pub fn is_decidable(&self) -> bool {
189        self.decidable
190    }
191
192    /// Why this pattern could not be decided, if it could not.
193    ///
194    /// A refusal has to be actionable. "These grants could not be compared" is
195    /// not; "`*.rs` is a partial-segment glob, which the solver does not
196    /// decide" tells the author exactly what to change.
197    pub fn undecidable_reason(&self) -> Option<Undecidable> {
198        self.segments.iter().find_map(|s| match s {
199            Seg::Opaque(raw) if raw.contains('{') || raw.contains('}') => {
200                Some(Undecidable::Template {
201                    segment: raw.clone(),
202                })
203            }
204            Seg::Opaque(raw) if raw == "**" => Some(Undecidable::NonFinalRecursive),
205            Seg::Opaque(raw) => Some(Undecidable::PartialGlob {
206                segment: raw.clone(),
207            }),
208            _ => None,
209        })
210    }
211}
212
213/// Why a pattern falls outside the decidable fragment.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum Undecidable {
216    /// A `{template}` segment, symbolic until the runtime resolves it.
217    Template {
218        /// The offending segment.
219        segment: String,
220    },
221    /// A glob inside a segment, such as `*.rs`, rather than a whole segment.
222    PartialGlob {
223        /// The offending segment.
224        segment: String,
225    },
226    /// `**` somewhere other than the final position.
227    NonFinalRecursive,
228}
229
230impl fmt::Display for Undecidable {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            Self::Template { segment } => write!(
234                f,
235                "`{segment}` is a template, which stays symbolic until the runtime resolves and clamps it"
236            ),
237            Self::PartialGlob { segment } => write!(
238                f,
239                "`{segment}` globs within a segment; the solver decides whole-segment `*` and trailing `**` only"
240            ),
241            Self::NonFinalRecursive => f.write_str(
242                "`**` appears before the end of the pattern; it is decided only in final position",
243            ),
244        }
245    }
246}
247
248impl Pattern {
249    /// A canonical rendering, so two spellings of the same pattern compare
250    /// equal (`/src/` and `/src`).
251    pub fn canonical(&self) -> String {
252        if self.segments.is_empty() {
253            return String::new();
254        }
255        self.segments
256            .iter()
257            .map(|s| match s {
258                Seg::Literal(l) => l.as_str(),
259                Seg::Single => "*",
260                Seg::Recursive => "**",
261                Seg::Opaque(raw) => raw.as_str(),
262            })
263            .collect::<Vec<_>>()
264            .join("/")
265    }
266}
267
268impl fmt::Display for Pattern {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        f.write_str(&self.canonical())
271    }
272}
273
274/// Whether every resource matched by `inner` is also matched by `outer`.
275///
276/// This is the containment relation the partial order is built from: `outer`
277/// permits at least as much as `inner`.
278pub fn covers(outer: &str, inner: &str) -> Tri {
279    // Reflexivity must hold even outside the decidable fragment: a template is
280    // always exactly as permissive as itself.
281    if outer == inner {
282        return Tri::Yes;
283    }
284
285    let o = Pattern::parse(outer);
286    let i = Pattern::parse(inner);
287
288    // Same denotation, spelled differently.
289    if o == i {
290        return Tri::Yes;
291    }
292    if !o.decidable || !i.decidable {
293        return Tri::Unknown;
294    }
295
296    covers_segments(&o.segments, &i.segments)
297}
298
299fn covers_segments(outer: &[Seg], inner: &[Seg]) -> Tri {
300    match (outer.first(), inner.first()) {
301        // Both exhausted: the same depth, everything matched so far.
302        (None, None) => Tri::Yes,
303        // `**` is final and absorbs whatever remains, including nothing.
304        (Some(Seg::Recursive), _) => Tri::Yes,
305        // Outer ran out while inner goes deeper, or needs depth inner lacks.
306        (None, Some(_)) | (Some(_), None) => Tri::No,
307
308        (Some(Seg::Single), Some(inner_seg)) => match inner_seg {
309            // A single segment cannot cover an unbounded tail.
310            Seg::Recursive => Tri::No,
311            Seg::Opaque(_) => Tri::Unknown,
312            _ => covers_segments(&outer[1..], &inner[1..]),
313        },
314
315        (Some(Seg::Literal(a)), Some(inner_seg)) => match inner_seg {
316            Seg::Literal(b) if a == b => covers_segments(&outer[1..], &inner[1..]),
317            // A literal covers only itself; anything wider escapes it.
318            Seg::Literal(_) | Seg::Single | Seg::Recursive => Tri::No,
319            Seg::Opaque(_) => Tri::Unknown,
320        },
321
322        (Some(Seg::Opaque(_)), _) => Tri::Unknown,
323    }
324}
325
326/// Whether two patterns could ever match the same concrete resource.
327///
328/// This is the question `par` disjointness turns on: not "is one inside the
329/// other", but "can these two touch the same thing at all".
330pub fn overlaps(a: &str, b: &str) -> Tri {
331    if a == b {
332        return Tri::Yes;
333    }
334
335    let pa = Pattern::parse(a);
336    let pb = Pattern::parse(b);
337
338    if pa == pb {
339        return Tri::Yes;
340    }
341    if !pa.decidable || !pb.decidable {
342        return Tri::Unknown;
343    }
344
345    overlaps_segments(&pa.segments, &pb.segments)
346}
347
348/// The exact intersection of two patterns, if the fragment can express it.
349///
350/// Returns the resource denoting precisely what both patterns admit, or `None`
351/// when they admit nothing in common or the pair is undecidable.
352///
353/// The decidable fragment **is** closed under intersection, which is what makes
354/// [`meet`](crate::meet) exact rather than approximate: a literal meets `*` as
355/// that literal, `*` meets `*` as `*`, and a trailing `**` yields whatever the
356/// other side has left. Picking one side and discarding the other instead
357/// would break associativity — `/*` and `/a/**` share `/a`, and a rule that
358/// dropped the pair would lose it.
359pub fn intersect(a: &str, b: &str) -> Option<String> {
360    let pa = Pattern::parse(a);
361    let pb = Pattern::parse(b);
362
363    // Identical patterns intersect to themselves, decidable or not.
364    if pa == pb {
365        return Some(pa.canonical());
366    }
367    if !pa.decidable || !pb.decidable {
368        return None;
369    }
370
371    intersect_segments(pa.segments(), pb.segments()).map(|segs| {
372        Pattern {
373            segments: segs,
374            decidable: true,
375        }
376        .canonical()
377    })
378}
379
380fn intersect_segments(a: &[Seg], b: &[Seg]) -> Option<Vec<Seg>> {
381    match (a.first(), b.first()) {
382        (None, None) => Some(Vec::new()),
383
384        // A trailing `**` matches zero or more segments, so the intersection
385        // from here on is simply whatever the other side still requires.
386        // These arms must precede the depth-mismatch arm below.
387        (Some(Seg::Recursive), _) => Some(b.to_vec()),
388        (_, Some(Seg::Recursive)) => Some(a.to_vec()),
389
390        // One side is deeper and neither can absorb the difference.
391        (None, Some(_)) | (Some(_), None) => None,
392
393        (Some(Seg::Opaque(_)), _) | (_, Some(Seg::Opaque(_))) => None,
394
395        (Some(head_a), Some(head_b)) => {
396            let head = match (head_a, head_b) {
397                (Seg::Literal(x), Seg::Literal(y)) if x == y => Seg::Literal(x.clone()),
398                (Seg::Literal(_), Seg::Literal(_)) => return None,
399                // `*` admits any single segment, so the literal is the narrower.
400                (Seg::Literal(x), Seg::Single) | (Seg::Single, Seg::Literal(x)) => {
401                    Seg::Literal(x.clone())
402                }
403                (Seg::Single, Seg::Single) => Seg::Single,
404                _ => return None,
405            };
406            let mut rest = intersect_segments(&a[1..], &b[1..])?;
407            rest.insert(0, head);
408            Some(rest)
409        }
410    }
411}
412
413fn overlaps_segments(a: &[Seg], b: &[Seg]) -> Tri {
414    match (a.first(), b.first()) {
415        (None, None) => Tri::Yes,
416        // A trailing `**` matches zero or more, so it meets whatever is left.
417        (Some(Seg::Recursive), _) | (_, Some(Seg::Recursive)) => Tri::Yes,
418        // One side is deeper than the other, and neither can absorb the rest.
419        (None, Some(_)) | (Some(_), None) => Tri::No,
420
421        (Some(Seg::Opaque(_)), _) | (_, Some(Seg::Opaque(_))) => Tri::Unknown,
422
423        // `*` matches any single segment, so it meets anything at this depth.
424        (Some(Seg::Single), Some(_)) | (Some(_), Some(Seg::Single)) => {
425            overlaps_segments(&a[1..], &b[1..])
426        }
427
428        (Some(Seg::Literal(x)), Some(Seg::Literal(y))) => {
429            if x == y {
430                overlaps_segments(&a[1..], &b[1..])
431            } else {
432                Tri::No
433            }
434        }
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn tri_conjunction_lets_a_definite_no_win() {
444        assert_eq!(Tri::No.and(Tri::Unknown), Tri::No);
445        assert_eq!(Tri::Unknown.and(Tri::Yes), Tri::Unknown);
446        assert_eq!(Tri::Yes.and(Tri::Yes), Tri::Yes);
447    }
448
449    #[test]
450    fn tri_disjunction_lets_a_definite_yes_win() {
451        assert_eq!(Tri::Yes.or(Tri::Unknown), Tri::Yes);
452        assert_eq!(Tri::Unknown.or(Tri::No), Tri::Unknown);
453        assert_eq!(Tri::No.or(Tri::No), Tri::No);
454    }
455
456    #[test]
457    fn unknown_never_reads_as_true() {
458        assert!(!Tri::Unknown.is_yes());
459        assert!(!Tri::Unknown.is_no());
460    }
461
462    // ── covers ──────────────────────────────────────────────────────────────
463
464    #[test]
465    fn a_pattern_covers_itself() {
466        for p in ["/src/**", "/src", "*", "{path}", "*.rs", "", "a/**/b"] {
467            assert_eq!(covers(p, p), Tri::Yes, "{p} must cover itself");
468        }
469    }
470
471    #[test]
472    fn recursive_glob_covers_everything_beneath_it() {
473        assert_eq!(covers("/src/**", "/src/lib/a.rs"), Tri::Yes);
474        assert_eq!(covers("/src/**", "/src/lib"), Tri::Yes);
475        // `**` matches zero segments, so it covers the directory itself.
476        assert_eq!(covers("/src/**", "/src"), Tri::Yes);
477        assert_eq!(covers("/src/**", "/src/*"), Tri::Yes);
478    }
479
480    #[test]
481    fn a_recursive_glob_is_not_covered_by_anything_narrower() {
482        assert_eq!(covers("/src", "/src/**"), Tri::No);
483        assert_eq!(covers("/src/*", "/src/**"), Tri::No);
484    }
485
486    #[test]
487    fn disjoint_prefixes_do_not_cover_each_other() {
488        assert_eq!(covers("/src/**", "/etc/passwd"), Tri::No);
489        assert_eq!(covers("/etc/passwd", "/src/**"), Tri::No);
490    }
491
492    #[test]
493    fn single_glob_matches_exactly_one_segment() {
494        assert_eq!(covers("/src/*", "/src/lib"), Tri::Yes);
495        assert_eq!(covers("/src/*", "/src/lib/a.rs"), Tri::No);
496        assert_eq!(covers("/src/*", "/src"), Tri::No);
497    }
498
499    #[test]
500    fn a_trailing_separator_does_not_change_meaning() {
501        assert_eq!(covers("/src/", "/src"), Tri::Yes);
502        assert_eq!(covers("/src", "/src/"), Tri::Yes);
503    }
504
505    #[test]
506    fn non_path_resources_compare_as_single_segments() {
507        assert_eq!(
508            covers("api.example.com:443", "api.example.com:443"),
509            Tri::Yes
510        );
511        assert_eq!(
512            covers("api.example.com:443", "evil.example.com:443"),
513            Tri::No
514        );
515        assert_eq!(covers("*", "api.example.com:443"), Tri::Yes);
516    }
517
518    #[test]
519    fn bare_resources_only_cover_each_other() {
520        assert_eq!(covers("", ""), Tri::Yes);
521        assert_eq!(covers("", "/src"), Tri::No);
522        assert_eq!(covers("/src", ""), Tri::No);
523    }
524
525    /// Regression: two *different* undecidable patterns must not be mistaken
526    /// for each other. If opaque segments compared equal regardless of their
527    /// text, these would parse identically and the equality short-circuit
528    /// would report mutual containment — granting `*.py` to something that
529    /// only asked for `*.rs`.
530    #[test]
531    fn distinct_undecidable_patterns_are_never_treated_as_equal() {
532        assert_eq!(covers("/src/*.rs", "/src/*.py"), Tri::Unknown);
533        assert_eq!(covers("/src/*.py", "/src/*.rs"), Tri::Unknown);
534        assert_eq!(covers("{path}", "{other}"), Tri::Unknown);
535        assert_eq!(overlaps("/src/*.rs", "/src/*.py"), Tri::Unknown);
536
537        assert_ne!(
538            Pattern::parse("/src/*.rs"),
539            Pattern::parse("/src/*.py"),
540            "opaque segments must carry their text"
541        );
542    }
543
544    #[test]
545    fn undecidable_patterns_report_unknown_rather_than_guessing() {
546        // Partial-segment globs.
547        assert_eq!(covers("/src/*.rs", "/src/main.rs"), Tri::Unknown);
548        // Templates, unresolved until runtime.
549        assert_eq!(covers("/src/**", "{path}"), Tri::Unknown);
550        assert_eq!(covers("{path}", "/src/a"), Tri::Unknown);
551        // `**` anywhere but the end.
552        assert_eq!(covers("/a/**/b", "/a/x/b"), Tri::Unknown);
553    }
554
555    // ── overlaps ────────────────────────────────────────────────────────────
556
557    #[test]
558    fn identical_and_nested_patterns_overlap() {
559        assert_eq!(overlaps("/src/**", "/src/**"), Tri::Yes);
560        assert_eq!(overlaps("/src/**", "/src/lib/a.rs"), Tri::Yes);
561        assert_eq!(overlaps("/src/lib/a.rs", "/src/**"), Tri::Yes);
562    }
563
564    #[test]
565    fn sibling_paths_do_not_overlap() {
566        assert_eq!(overlaps("/src/**", "/build/**"), Tri::No);
567        assert_eq!(overlaps("/src/a", "/src/b"), Tri::No);
568    }
569
570    #[test]
571    fn overlap_is_symmetric() {
572        let cases = [
573            ("/src/**", "/src/lib"),
574            ("/src/**", "/build/**"),
575            ("/src/*", "/src/a"),
576            ("/a/b", "/a/b/c"),
577        ];
578        for (x, y) in cases {
579            assert_eq!(overlaps(x, y), overlaps(y, x), "{x} vs {y}");
580        }
581    }
582
583    #[test]
584    fn different_depths_do_not_overlap_without_a_recursive_glob() {
585        assert_eq!(overlaps("/a/b", "/a/b/c"), Tri::No);
586        assert_eq!(overlaps("/a/*", "/a/b/c"), Tri::No);
587    }
588
589    #[test]
590    fn overlap_is_unknown_for_undecidable_patterns() {
591        assert_eq!(overlaps("/src/*.rs", "/src/main.rs"), Tri::Unknown);
592        assert_eq!(overlaps("{path}", "/src/a"), Tri::Unknown);
593    }
594
595    #[test]
596    fn covering_implies_overlapping() {
597        let pairs = [
598            ("/src/**", "/src/lib/a.rs"),
599            ("/src/*", "/src/lib"),
600            ("/src", "/src"),
601        ];
602        for (outer, inner) in pairs {
603            assert_eq!(covers(outer, inner), Tri::Yes);
604            assert_eq!(
605                overlaps(outer, inner),
606                Tri::Yes,
607                "if {outer} covers {inner} they must overlap"
608            );
609        }
610    }
611}