1use boundgraph_core::{GrantAtom, GrantSet};
20
21use crate::access::{Access, classify};
22use crate::trie::ResourceTrie;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ConflictKind {
27 WriteWrite,
29 WriteRead,
31 ReadWrite,
33}
34
35impl ConflictKind {
36 pub fn describe(self) -> &'static str {
38 match self {
39 Self::WriteWrite => "both branches write it",
40 Self::WriteRead => "the left branch writes what the right branch reads",
41 Self::ReadWrite => "the left branch reads what the right branch writes",
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Conflict {
53 pub left: GrantAtom,
55 pub right: GrantAtom,
57 pub kind: ConflictKind,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Disjointness {
64 Disjoint,
67 Overlapping(Conflict),
69 Undecided(Conflict),
71}
72
73impl Disjointness {
74 pub fn is_proved_disjoint(&self) -> bool {
79 matches!(self, Self::Disjoint)
80 }
81
82 pub fn conflict(&self) -> Option<&Conflict> {
84 match self {
85 Self::Disjoint => None,
86 Self::Overlapping(c) | Self::Undecided(c) => Some(c),
87 }
88 }
89}
90
91fn partition(set: &GrantSet) -> (Vec<&GrantAtom>, Vec<&GrantAtom>) {
93 set.iter()
94 .partition(|a| classify(&a.action) == Access::Read)
95}
96
97pub fn disjoint(a: &GrantSet, b: &GrantSet) -> Disjointness {
104 let (a_reads, a_writes) = partition(a);
105 let (b_reads, b_writes) = partition(b);
106
107 let checks = [
109 (&a_writes, &b_reads, ConflictKind::WriteRead),
110 (&a_reads, &b_writes, ConflictKind::ReadWrite),
111 (&a_writes, &b_writes, ConflictKind::WriteWrite),
112 ];
113
114 let mut undecided: Option<Conflict> = None;
115
116 for (left, right, kind) in checks {
117 let trie = ResourceTrie::build(right.iter().copied());
118
119 for l in left.iter() {
120 let probe = trie.probe(l);
121
122 if let Some(index) = probe.hit {
123 return Disjointness::Overlapping(Conflict {
124 left: (*l).clone(),
125 right: trie.atom(index).clone(),
126 kind,
127 });
128 }
129
130 if probe.undecided && undecided.is_none() {
131 let counterpart = right
134 .iter()
135 .find(|r| r.domain == l.domain)
136 .or_else(|| right.first())
137 .map(|r| (*r).clone())
138 .unwrap_or_else(|| (*l).clone());
139 undecided = Some(Conflict {
140 left: (*l).clone(),
141 right: counterpart,
142 kind,
143 });
144 }
145 }
146 }
147
148 undecided.map_or(Disjointness::Disjoint, Disjointness::Undecided)
149}
150
151pub fn all_disjoint<'a>(branches: impl IntoIterator<Item = &'a GrantSet>) -> Disjointness {
156 let sets: Vec<&GrantSet> = branches.into_iter().collect();
157 let mut undecided: Option<Conflict> = None;
158
159 for i in 0..sets.len() {
160 for j in (i + 1)..sets.len() {
161 match disjoint(sets[i], sets[j]) {
162 Disjointness::Disjoint => {}
163 overlapping @ Disjointness::Overlapping(_) => return overlapping,
164 Disjointness::Undecided(c) => {
165 undecided.get_or_insert(c);
166 }
167 }
168 }
169 }
170
171 undecided.map_or(Disjointness::Disjoint, Disjointness::Undecided)
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn set(atoms: &[&str]) -> GrantSet {
179 GrantSet::parse(atoms.iter().copied()).expect("test set should parse")
180 }
181
182 #[test]
183 fn two_reads_of_the_same_file_are_disjoint() {
184 let a = set(&["fs:read:/src/**"]);
186 let b = set(&["fs:read:/src/**"]);
187 assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
188 }
189
190 #[test]
191 fn two_writes_of_the_same_file_are_not() {
192 let a = set(&["fs:write:/build/out"]);
193 let b = set(&["fs:write:/build/out"]);
194 let result = disjoint(&a, &b);
195 assert!(!result.is_proved_disjoint());
196 assert_eq!(result.conflict().unwrap().kind, ConflictKind::WriteWrite);
197 }
198
199 #[test]
200 fn a_write_against_a_read_is_a_race() {
201 let writer = set(&["fs:write:/src/a.rs"]);
202 let reader = set(&["fs:read:/src/**"]);
203
204 let forward = disjoint(&writer, &reader);
205 assert_eq!(forward.conflict().unwrap().kind, ConflictKind::WriteRead);
206
207 let backward = disjoint(&reader, &writer);
208 assert_eq!(backward.conflict().unwrap().kind, ConflictKind::ReadWrite);
209 }
210
211 #[test]
212 fn unrelated_paths_are_disjoint() {
213 let a = set(&["fs:write:/build/**"]);
214 let b = set(&["fs:write:/dist/**"]);
215 assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
216 }
217
218 #[test]
219 fn different_domains_never_conflict() {
220 let a = set(&["fs:write:/src/a"]);
221 let b = set(&["net:egress:api.example.com:443"]);
222 assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
223 }
224
225 #[test]
226 fn nesting_counts_as_overlap() {
227 let a = set(&["fs:write:/src/lib/a.rs"]);
228 let b = set(&["fs:read:/src/**"]);
229 assert!(!disjoint(&a, &b).is_proved_disjoint());
230 }
231
232 #[test]
233 fn the_offending_pair_is_reported() {
234 let a = set(&["fs:write:/build/out", "fs:read:/src/**"]);
235 let b = set(&["fs:read:/build/out", "fs:read:/docs/**"]);
236
237 let c = disjoint(&a, &b)
238 .conflict()
239 .cloned()
240 .expect("should conflict");
241 assert_eq!(c.left.to_string(), "fs:write:/build/out");
242 assert_eq!(c.right.to_string(), "fs:read:/build/out");
243 assert_eq!(c.kind, ConflictKind::WriteRead);
244 assert!(!c.kind.describe().is_empty());
245 }
246
247 #[test]
248 fn an_undecidable_pair_is_undecided_not_disjoint() {
249 let a = set(&["fs:write:/src/*.rs"]);
250 let b = set(&["fs:read:/src/main.rs"]);
251 let result = disjoint(&a, &b);
252 assert!(matches!(result, Disjointness::Undecided(_)));
253 assert!(
254 !result.is_proved_disjoint(),
255 "an undecided proof must never read as safe"
256 );
257 }
258
259 #[test]
260 fn a_proved_conflict_outranks_an_undecided_one() {
261 let a = set(&["fs:write:/src/*.rs", "fs:write:/build/out"]);
262 let b = set(&["fs:read:/build/out"]);
263 assert!(matches!(disjoint(&a, &b), Disjointness::Overlapping(_)));
264 }
265
266 #[test]
267 fn exec_and_egress_are_treated_as_writes() {
268 let a = set(&["proc:exec:git"]);
270 let b = set(&["proc:exec:git"]);
271 assert!(!disjoint(&a, &b).is_proved_disjoint());
272
273 let x = set(&["net:egress:api.example.com:443"]);
274 let y = set(&["net:egress:api.example.com:443"]);
275 assert!(!disjoint(&x, &y).is_proved_disjoint());
276 }
277
278 #[test]
279 fn two_branches_using_the_same_secret_are_disjoint() {
280 let a = set(&["secret:use:stripe_key"]);
281 let b = set(&["secret:use:stripe_key"]);
282 assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
283 }
284
285 #[test]
286 fn empty_grants_are_disjoint_from_everything() {
287 let busy = set(&["fs:write:/**"]);
288 assert_eq!(disjoint(&GrantSet::EMPTY, &busy), Disjointness::Disjoint);
289 assert_eq!(disjoint(&busy, &GrantSet::EMPTY), Disjointness::Disjoint);
290 }
291
292 #[test]
293 fn disjointness_is_symmetric_in_its_verdict() {
294 let cases = [
295 (set(&["fs:write:/a"]), set(&["fs:read:/a"])),
296 (set(&["fs:write:/a"]), set(&["fs:write:/b"])),
297 (set(&["fs:read:/a"]), set(&["fs:read:/a"])),
298 (set(&["fs:write:/src/*.rs"]), set(&["fs:read:/src/main.rs"])),
299 ];
300 for (a, b) in cases {
301 assert_eq!(
302 disjoint(&a, &b).is_proved_disjoint(),
303 disjoint(&b, &a).is_proved_disjoint(),
304 "verdict must not depend on argument order: {a:?} vs {b:?}"
305 );
306 }
307 }
308
309 #[test]
310 fn a_branch_is_never_disjoint_from_itself_when_it_writes() {
311 let w = set(&["fs:write:/build/**"]);
312 assert!(!disjoint(&w, &w).is_proved_disjoint());
313 }
314
315 #[test]
318 fn pairwise_disjointness_across_several_branches() {
319 let branches = [
320 set(&["fs:write:/a/**"]),
321 set(&["fs:write:/b/**"]),
322 set(&["fs:write:/c/**"]),
323 ];
324 assert_eq!(all_disjoint(&branches), Disjointness::Disjoint);
325 }
326
327 #[test]
328 fn one_bad_pair_sinks_the_whole_par() {
329 let branches = [
330 set(&["fs:write:/a/**"]),
331 set(&["fs:write:/b/**"]),
332 set(&["fs:read:/a/thing"]), ];
334 let result = all_disjoint(&branches);
335 assert!(!result.is_proved_disjoint());
336 assert_eq!(result.conflict().unwrap().kind, ConflictKind::WriteRead);
337 }
338
339 #[test]
340 fn fewer_than_two_branches_are_trivially_disjoint() {
341 assert_eq!(all_disjoint(std::iter::empty()), Disjointness::Disjoint);
342 let single = [set(&["fs:write:/a/**"])];
343 assert_eq!(all_disjoint(&single), Disjointness::Disjoint);
344 }
345}