Skip to main content

boundgraph_grant/
require.rs

1//! Turning an undecided answer into a refusal.
2//!
3//! The lattice reports three values; the compiler acts on two. This module is
4//! the boundary, and it collapses [`Tri::Unknown`] into an error every time.
5//!
6//! # There is no permissive mode
7//!
8//! Deliberately, there is no flag, feature, or alternate entry point that lets
9//! an `Unknown` through as a pass. That is Goal G5 — if a property cannot be
10//! *proved*, reject or degrade, never assume — and it only holds if there is no
11//! escape hatch. An escape hatch would be used, and it would be used on the day
12//! someone is in a hurry.
13//!
14//! The cost of that strictness is a build error on a pattern the solver cannot
15//! decide. The cost of the alternative is a permission granted on a guess. The
16//! two are not comparable, so the strict reading wins.
17//!
18//! Every refusal names the offending pair and says *why*, so the fix is
19//! obvious: narrow a grant, spell a pattern inside the decidable fragment, or
20//! degrade a `par` to a `seq`.
21
22use boundgraph_core::{GrantAtom, GrantSet};
23
24use crate::disjoint::{Conflict, Disjointness, disjoint};
25use crate::order::{atom_leq, set_leq};
26use crate::pattern::{Pattern, Tri, Undecidable};
27
28/// Why the solver refused a flow.
29///
30/// Grant payloads are boxed. `Refusal` is the error half of the compiler's
31/// `Result`s, so its size is paid on the success path too, and a pair of atoms
32/// plus a set is well past the point where that starts to cost. The
33/// constructors hide the boxing; only exhaustive matches see it.
34#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
35pub enum Refusal {
36    /// A grant widened along an edge. Law I forbids it.
37    #[error("`{atom}` is not permitted by the parent grant {parent:?}")]
38    Widened {
39        /// The atom that escaped.
40        atom: Box<GrantAtom>,
41        /// What the parent actually permits.
42        parent: Box<GrantSet>,
43    },
44
45    /// Containment could not be decided, so the flow is refused.
46    #[error("cannot prove `{atom}` is within the parent grant: {reason}")]
47    UndecidableContainment {
48        /// The atom that could not be compared.
49        atom: Box<GrantAtom>,
50        /// The parent atom it was compared against.
51        against: Box<GrantAtom>,
52        /// Why the comparison could not be made.
53        reason: Undecidable,
54    },
55
56    /// Two branches were proved to interfere.
57    #[error("`{}` and `{}` can touch the same resource: {}", .0.left, .0.right, .0.kind.describe())]
58    Interference(Box<Conflict>),
59
60    /// Two branches could not be proved not to interfere.
61    #[error("cannot prove `{}` and `{}` are disjoint: {reason}", .conflict.left, .conflict.right)]
62    UndecidableDisjointness {
63        /// The pair that could not be decided.
64        conflict: Box<Conflict>,
65        /// Why the comparison could not be made.
66        reason: Undecidable,
67    },
68}
69
70impl Refusal {
71    /// Whether this refusal is a proved violation rather than a solver gap.
72    ///
73    /// Both refuse the flow. The distinction is for diagnostics: a proved
74    /// violation means the flow is wrong, an undecided one means the flow may
75    /// be fine but is spelled in a way the solver cannot check.
76    pub fn is_proved(&self) -> bool {
77        matches!(self, Self::Widened { .. } | Self::Interference(_))
78    }
79
80    /// A suggested fix, phrased for the author.
81    pub fn suggestion(&self) -> &'static str {
82        match self {
83            Self::Widened { .. } => "narrow the child grant, or widen the parent behind a gate",
84            Self::UndecidableContainment { .. } => {
85                "respell the resource using whole-segment `*` or a trailing `**`"
86            }
87            Self::Interference(_) => "narrow one of the two grants, or replace `par` with `seq`",
88            Self::UndecidableDisjointness { .. } => {
89                "respell the resource using whole-segment `*` or a trailing `**`, or use `seq`"
90            }
91        }
92    }
93}
94
95/// Require that `child` is within `parent` — the Law I attenuation check.
96///
97/// # Errors
98///
99/// Returns [`Refusal::Widened`] when a child atom is proved to escape, and
100/// [`Refusal::UndecidableContainment`] when it merely cannot be proved not to.
101/// **Both are refusals**: an undecided comparison never passes.
102pub fn require_attenuated(child: &GrantSet, parent: &GrantSet) -> Result<(), Refusal> {
103    match set_leq(child, parent) {
104        Tri::Yes => Ok(()),
105        // Re-walk to name the specific atom rather than blame the whole set.
106        _ => Err(explain_widening(child, parent)),
107    }
108}
109
110/// Require that two branches cannot interfere — the `par` proof.
111///
112/// # Errors
113///
114/// Returns [`Refusal::Interference`] on a proved conflict and
115/// [`Refusal::UndecidableDisjointness`] when disjointness could not be proved.
116/// **Both are refusals.**
117pub fn require_disjoint(a: &GrantSet, b: &GrantSet) -> Result<(), Refusal> {
118    match disjoint(a, b) {
119        Disjointness::Disjoint => Ok(()),
120        Disjointness::Overlapping(c) => Err(Refusal::Interference(Box::new(c))),
121        Disjointness::Undecided(c) => {
122            let reason = reason_for(&c.left)
123                .or_else(|| reason_for(&c.right))
124                .unwrap_or(Undecidable::NonFinalRecursive);
125            Err(Refusal::UndecidableDisjointness {
126                conflict: Box::new(c),
127                reason,
128            })
129        }
130    }
131}
132
133/// Find the atom responsible and say what went wrong.
134///
135/// A proved widening is reported in preference to an undecided comparison, so
136/// the author sees the real problem when there is one.
137fn explain_widening(child: &GrantSet, parent: &GrantSet) -> Refusal {
138    let mut undecided: Option<Refusal> = None;
139
140    for atom in child.iter() {
141        match Tri::any(parent.iter().map(|p| atom_leq(atom, p))) {
142            Tri::Yes => continue,
143            Tri::No => {
144                return Refusal::Widened {
145                    atom: Box::new(atom.clone()),
146                    parent: Box::new(parent.clone()),
147                };
148            }
149            Tri::Unknown if undecided.is_none() => {
150                let against = parent
151                    .iter()
152                    .find(|p| atom_leq(atom, p).is_unknown())
153                    .cloned()
154                    .unwrap_or_else(|| atom.clone());
155                let reason = reason_for(atom)
156                    .or_else(|| reason_for(&against))
157                    .unwrap_or(Undecidable::NonFinalRecursive);
158                undecided = Some(Refusal::UndecidableContainment {
159                    atom: Box::new(atom.clone()),
160                    against: Box::new(against),
161                    reason,
162                });
163            }
164            Tri::Unknown => {}
165        }
166    }
167
168    undecided.unwrap_or_else(|| Refusal::Widened {
169        atom: Box::new(
170            child
171                .iter()
172                .next()
173                .cloned()
174                .unwrap_or_else(|| GrantAtom::new("unknown", "unknown", "")),
175        ),
176        parent: Box::new(parent.clone()),
177    })
178}
179
180fn reason_for(atom: &GrantAtom) -> Option<Undecidable> {
181    Pattern::parse(&atom.resource).undecidable_reason()
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn set(atoms: &[&str]) -> GrantSet {
189        GrantSet::parse(atoms.iter().copied()).expect("test set should parse")
190    }
191
192    #[test]
193    fn a_narrowing_child_is_accepted() {
194        let parent = set(&["fs:read:/src/**"]);
195        let child = set(&["fs:read:/src/lib/a.rs"]);
196        assert!(require_attenuated(&child, &parent).is_ok());
197    }
198
199    #[test]
200    fn a_widening_child_is_refused_and_named() {
201        let parent = set(&["fs:read:/src/**"]);
202        let child = set(&["fs:read:/etc/passwd"]);
203
204        let err = require_attenuated(&child, &parent).unwrap_err();
205        assert!(err.is_proved());
206        assert!(
207            err.to_string().contains("/etc/passwd"),
208            "the message must name the offending atom: {err}"
209        );
210        assert!(!err.suggestion().is_empty());
211    }
212
213    #[test]
214    fn an_undecidable_child_is_refused_not_admitted() {
215        let parent = set(&["fs:read:/src/**"]);
216        let child = set(&["fs:read:/src/*.rs"]);
217
218        let err = require_attenuated(&child, &parent)
219            .expect_err("an undecided comparison must never pass");
220        assert!(
221            !err.is_proved(),
222            "it is a solver gap, not a proved violation"
223        );
224    }
225
226    #[test]
227    fn a_refusal_names_both_patterns_and_the_reason() {
228        let parent = set(&["fs:read:/src/**"]);
229        let child = set(&["fs:read:/src/*.rs"]);
230
231        let err = require_attenuated(&child, &parent).unwrap_err();
232        let Refusal::UndecidableContainment {
233            atom,
234            against,
235            reason,
236        } = &err
237        else {
238            panic!("expected an undecidable containment, got {err:?}");
239        };
240
241        assert_eq!(atom.to_string(), "fs:read:/src/*.rs");
242        assert_eq!(against.to_string(), "fs:read:/src/**");
243        assert!(matches!(reason, Undecidable::PartialGlob { .. }));
244        assert!(
245            reason.to_string().contains("*.rs"),
246            "the reason must quote the offending segment: {reason}"
247        );
248    }
249
250    #[test]
251    fn each_undecidable_kind_explains_itself_differently() {
252        let cases = [
253            ("fs:read:{path}", "template"),
254            ("fs:read:/src/*.rs", "globs within a segment"),
255            ("fs:read:/a/**/b", "before the end"),
256        ];
257        for (resource, needle) in cases {
258            let atom: GrantAtom = resource.parse().unwrap();
259            let reason = reason_for(&atom).expect("should be undecidable");
260            assert!(
261                reason.to_string().contains(needle),
262                "{resource}: expected {needle:?} in {reason}"
263            );
264        }
265    }
266
267    #[test]
268    fn a_proved_widening_is_reported_before_an_undecided_sibling() {
269        let parent = set(&["fs:read:/src/**"]);
270        let child = set(&["fs:read:/src/*.rs", "fs:read:/etc/passwd"]);
271        let err = require_attenuated(&child, &parent).unwrap_err();
272        assert!(err.is_proved(), "report the real violation, not the gap");
273    }
274
275    #[test]
276    fn disjoint_branches_are_accepted() {
277        assert!(require_disjoint(&set(&["fs:write:/a/**"]), &set(&["fs:write:/b/**"])).is_ok());
278    }
279
280    #[test]
281    fn interfering_branches_are_refused() {
282        let err = require_disjoint(&set(&["fs:write:/a/x"]), &set(&["fs:read:/a/**"])).unwrap_err();
283        assert!(err.is_proved());
284        assert!(err.to_string().contains("/a/x"));
285    }
286
287    #[test]
288    fn undecidable_disjointness_is_refused_not_admitted() {
289        let err = require_disjoint(&set(&["fs:write:/a/*.rs"]), &set(&["fs:read:/a/main.rs"]))
290            .expect_err("an undecided proof must never pass");
291        assert!(!err.is_proved());
292        assert!(matches!(err, Refusal::UndecidableDisjointness { .. }));
293    }
294
295    /// `Refusal` rides in every compiler `Result`, so its size is paid whether
296    /// or not anything fails. This fails if a future variant unboxes a payload.
297    #[test]
298    fn the_refusal_type_stays_small() {
299        let size = size_of::<Refusal>();
300        assert!(
301            size <= 128,
302            "Refusal grew to {size} bytes; box the large fields of any new variant"
303        );
304    }
305
306    #[test]
307    fn the_empty_child_is_always_within_its_parent() {
308        assert!(require_attenuated(&GrantSet::EMPTY, &set(&["fs:read:/src/**"])).is_ok());
309        assert!(require_attenuated(&GrantSet::EMPTY, &GrantSet::EMPTY).is_ok());
310    }
311
312    #[test]
313    fn nothing_is_permitted_by_an_empty_parent() {
314        let err = require_attenuated(&set(&["fs:read:/src/a"]), &GrantSet::EMPTY).unwrap_err();
315        assert!(err.is_proved());
316    }
317}