use boundgraph_core::{GrantAtom, GrantSet};
use crate::access::{Access, classify};
use crate::trie::ResourceTrie;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictKind {
WriteWrite,
WriteRead,
ReadWrite,
}
impl ConflictKind {
pub fn describe(self) -> &'static str {
match self {
Self::WriteWrite => "both branches write it",
Self::WriteRead => "the left branch writes what the right branch reads",
Self::ReadWrite => "the left branch reads what the right branch writes",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Conflict {
pub left: GrantAtom,
pub right: GrantAtom,
pub kind: ConflictKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Disjointness {
Disjoint,
Overlapping(Conflict),
Undecided(Conflict),
}
impl Disjointness {
pub fn is_proved_disjoint(&self) -> bool {
matches!(self, Self::Disjoint)
}
pub fn conflict(&self) -> Option<&Conflict> {
match self {
Self::Disjoint => None,
Self::Overlapping(c) | Self::Undecided(c) => Some(c),
}
}
}
fn partition(set: &GrantSet) -> (Vec<&GrantAtom>, Vec<&GrantAtom>) {
set.iter()
.partition(|a| classify(&a.action) == Access::Read)
}
pub fn disjoint(a: &GrantSet, b: &GrantSet) -> Disjointness {
let (a_reads, a_writes) = partition(a);
let (b_reads, b_writes) = partition(b);
let checks = [
(&a_writes, &b_reads, ConflictKind::WriteRead),
(&a_reads, &b_writes, ConflictKind::ReadWrite),
(&a_writes, &b_writes, ConflictKind::WriteWrite),
];
let mut undecided: Option<Conflict> = None;
for (left, right, kind) in checks {
let trie = ResourceTrie::build(right.iter().copied());
for l in left.iter() {
let probe = trie.probe(l);
if let Some(index) = probe.hit {
return Disjointness::Overlapping(Conflict {
left: (*l).clone(),
right: trie.atom(index).clone(),
kind,
});
}
if probe.undecided && undecided.is_none() {
let counterpart = right
.iter()
.find(|r| r.domain == l.domain)
.or_else(|| right.first())
.map(|r| (*r).clone())
.unwrap_or_else(|| (*l).clone());
undecided = Some(Conflict {
left: (*l).clone(),
right: counterpart,
kind,
});
}
}
}
undecided.map_or(Disjointness::Disjoint, Disjointness::Undecided)
}
pub fn all_disjoint<'a>(branches: impl IntoIterator<Item = &'a GrantSet>) -> Disjointness {
let sets: Vec<&GrantSet> = branches.into_iter().collect();
let mut undecided: Option<Conflict> = None;
for i in 0..sets.len() {
for j in (i + 1)..sets.len() {
match disjoint(sets[i], sets[j]) {
Disjointness::Disjoint => {}
overlapping @ Disjointness::Overlapping(_) => return overlapping,
Disjointness::Undecided(c) => {
undecided.get_or_insert(c);
}
}
}
}
undecided.map_or(Disjointness::Disjoint, Disjointness::Undecided)
}
#[cfg(test)]
mod tests {
use super::*;
fn set(atoms: &[&str]) -> GrantSet {
GrantSet::parse(atoms.iter().copied()).expect("test set should parse")
}
#[test]
fn two_reads_of_the_same_file_are_disjoint() {
let a = set(&["fs:read:/src/**"]);
let b = set(&["fs:read:/src/**"]);
assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
}
#[test]
fn two_writes_of_the_same_file_are_not() {
let a = set(&["fs:write:/build/out"]);
let b = set(&["fs:write:/build/out"]);
let result = disjoint(&a, &b);
assert!(!result.is_proved_disjoint());
assert_eq!(result.conflict().unwrap().kind, ConflictKind::WriteWrite);
}
#[test]
fn a_write_against_a_read_is_a_race() {
let writer = set(&["fs:write:/src/a.rs"]);
let reader = set(&["fs:read:/src/**"]);
let forward = disjoint(&writer, &reader);
assert_eq!(forward.conflict().unwrap().kind, ConflictKind::WriteRead);
let backward = disjoint(&reader, &writer);
assert_eq!(backward.conflict().unwrap().kind, ConflictKind::ReadWrite);
}
#[test]
fn unrelated_paths_are_disjoint() {
let a = set(&["fs:write:/build/**"]);
let b = set(&["fs:write:/dist/**"]);
assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
}
#[test]
fn different_domains_never_conflict() {
let a = set(&["fs:write:/src/a"]);
let b = set(&["net:egress:api.example.com:443"]);
assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
}
#[test]
fn nesting_counts_as_overlap() {
let a = set(&["fs:write:/src/lib/a.rs"]);
let b = set(&["fs:read:/src/**"]);
assert!(!disjoint(&a, &b).is_proved_disjoint());
}
#[test]
fn the_offending_pair_is_reported() {
let a = set(&["fs:write:/build/out", "fs:read:/src/**"]);
let b = set(&["fs:read:/build/out", "fs:read:/docs/**"]);
let c = disjoint(&a, &b)
.conflict()
.cloned()
.expect("should conflict");
assert_eq!(c.left.to_string(), "fs:write:/build/out");
assert_eq!(c.right.to_string(), "fs:read:/build/out");
assert_eq!(c.kind, ConflictKind::WriteRead);
assert!(!c.kind.describe().is_empty());
}
#[test]
fn an_undecidable_pair_is_undecided_not_disjoint() {
let a = set(&["fs:write:/src/*.rs"]);
let b = set(&["fs:read:/src/main.rs"]);
let result = disjoint(&a, &b);
assert!(matches!(result, Disjointness::Undecided(_)));
assert!(
!result.is_proved_disjoint(),
"an undecided proof must never read as safe"
);
}
#[test]
fn a_proved_conflict_outranks_an_undecided_one() {
let a = set(&["fs:write:/src/*.rs", "fs:write:/build/out"]);
let b = set(&["fs:read:/build/out"]);
assert!(matches!(disjoint(&a, &b), Disjointness::Overlapping(_)));
}
#[test]
fn exec_and_egress_are_treated_as_writes() {
let a = set(&["proc:exec:git"]);
let b = set(&["proc:exec:git"]);
assert!(!disjoint(&a, &b).is_proved_disjoint());
let x = set(&["net:egress:api.example.com:443"]);
let y = set(&["net:egress:api.example.com:443"]);
assert!(!disjoint(&x, &y).is_proved_disjoint());
}
#[test]
fn two_branches_using_the_same_secret_are_disjoint() {
let a = set(&["secret:use:stripe_key"]);
let b = set(&["secret:use:stripe_key"]);
assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
}
#[test]
fn empty_grants_are_disjoint_from_everything() {
let busy = set(&["fs:write:/**"]);
assert_eq!(disjoint(&GrantSet::EMPTY, &busy), Disjointness::Disjoint);
assert_eq!(disjoint(&busy, &GrantSet::EMPTY), Disjointness::Disjoint);
}
#[test]
fn disjointness_is_symmetric_in_its_verdict() {
let cases = [
(set(&["fs:write:/a"]), set(&["fs:read:/a"])),
(set(&["fs:write:/a"]), set(&["fs:write:/b"])),
(set(&["fs:read:/a"]), set(&["fs:read:/a"])),
(set(&["fs:write:/src/*.rs"]), set(&["fs:read:/src/main.rs"])),
];
for (a, b) in cases {
assert_eq!(
disjoint(&a, &b).is_proved_disjoint(),
disjoint(&b, &a).is_proved_disjoint(),
"verdict must not depend on argument order: {a:?} vs {b:?}"
);
}
}
#[test]
fn a_branch_is_never_disjoint_from_itself_when_it_writes() {
let w = set(&["fs:write:/build/**"]);
assert!(!disjoint(&w, &w).is_proved_disjoint());
}
#[test]
fn pairwise_disjointness_across_several_branches() {
let branches = [
set(&["fs:write:/a/**"]),
set(&["fs:write:/b/**"]),
set(&["fs:write:/c/**"]),
];
assert_eq!(all_disjoint(&branches), Disjointness::Disjoint);
}
#[test]
fn one_bad_pair_sinks_the_whole_par() {
let branches = [
set(&["fs:write:/a/**"]),
set(&["fs:write:/b/**"]),
set(&["fs:read:/a/thing"]), ];
let result = all_disjoint(&branches);
assert!(!result.is_proved_disjoint());
assert_eq!(result.conflict().unwrap().kind, ConflictKind::WriteRead);
}
#[test]
fn fewer_than_two_branches_are_trivially_disjoint() {
assert_eq!(all_disjoint(std::iter::empty()), Disjointness::Disjoint);
let single = [set(&["fs:write:/a/**"])];
assert_eq!(all_disjoint(&single), Disjointness::Disjoint);
}
}