1use 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
35pub enum Refusal {
36 #[error("`{atom}` is not permitted by the parent grant {parent:?}")]
38 Widened {
39 atom: Box<GrantAtom>,
41 parent: Box<GrantSet>,
43 },
44
45 #[error("cannot prove `{atom}` is within the parent grant: {reason}")]
47 UndecidableContainment {
48 atom: Box<GrantAtom>,
50 against: Box<GrantAtom>,
52 reason: Undecidable,
54 },
55
56 #[error("`{}` and `{}` can touch the same resource: {}", .0.left, .0.right, .0.kind.describe())]
58 Interference(Box<Conflict>),
59
60 #[error("cannot prove `{}` and `{}` are disjoint: {reason}", .conflict.left, .conflict.right)]
62 UndecidableDisjointness {
63 conflict: Box<Conflict>,
65 reason: Undecidable,
67 },
68}
69
70impl Refusal {
71 pub fn is_proved(&self) -> bool {
77 matches!(self, Self::Widened { .. } | Self::Interference(_))
78 }
79
80 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
95pub fn require_attenuated(child: &GrantSet, parent: &GrantSet) -> Result<(), Refusal> {
103 match set_leq(child, parent) {
104 Tri::Yes => Ok(()),
105 _ => Err(explain_widening(child, parent)),
107 }
108}
109
110pub 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
133fn 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 #[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}