boundgraph_grant/
normalize.rs1use boundgraph_core::{GrantAtom, GrantSet};
12
13use crate::order::atom_leq;
14use crate::pattern::Tri;
15
16pub fn normalize(set: &GrantSet) -> GrantSet {
27 let atoms: Vec<&GrantAtom> = set.iter().collect();
28
29 let kept = atoms.iter().enumerate().filter_map(|(i, a)| {
30 let dominated = atoms.iter().enumerate().any(|(j, b)| {
31 if i == j {
32 return false;
33 }
34 match (atom_leq(a, b), atom_leq(b, a)) {
35 (Tri::Yes, Tri::Yes) => j < i,
38 (Tri::Yes, _) => true,
40 _ => false,
41 }
42 });
43 (!dominated).then(|| (*a).clone())
44 });
45
46 GrantSet::new(kept)
47}
48
49pub fn is_normal(set: &GrantSet) -> bool {
51 normalize(set) == *set
52}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57 use crate::order::set_eq;
58
59 fn set(atoms: &[&str]) -> GrantSet {
60 GrantSet::parse(atoms.iter().copied()).expect("test set should parse")
61 }
62
63 #[test]
64 fn a_contained_atom_is_removed() {
65 let g = set(&["fs:read:/src/**", "fs:read:/src/lib/a.rs"]);
66 assert_eq!(normalize(&g), set(&["fs:read:/src/**"]));
67 }
68
69 #[test]
70 fn unrelated_atoms_all_survive() {
71 let g = set(&[
72 "fs:read:/src/**",
73 "fs:write:/build/**",
74 "net:egress:api.example.com:443",
75 ]);
76 assert_eq!(normalize(&g), g);
77 }
78
79 #[test]
80 fn normalisation_is_idempotent() {
81 let g = set(&[
82 "fs:read:/src/**",
83 "fs:read:/src/lib/a.rs",
84 "fs:read:/src/lib",
85 "fs:write:/build/**",
86 ]);
87 let once = normalize(&g);
88 assert_eq!(normalize(&once), once);
89 assert!(is_normal(&once));
90 }
91
92 #[test]
93 fn meaning_is_preserved() {
94 let g = set(&["fs:read:/src/**", "fs:read:/src/lib/a.rs"]);
95 assert_eq!(set_eq(&normalize(&g), &g), Tri::Yes);
96 }
97
98 #[test]
99 fn equal_denotations_collapse_to_exactly_one() {
100 let g = set(&["fs:read:/src", "fs:read:/src/"]);
103 assert_eq!(g.len(), 2, "they are distinct as strings");
104
105 let n = normalize(&g);
106 assert_eq!(n.len(), 1, "but one denotation, so one atom survives");
107 assert_eq!(set_eq(&n, &g), Tri::Yes);
108 assert!(is_normal(&n));
109 }
110
111 #[test]
112 fn templates_are_never_merged_with_concrete_atoms() {
113 let g = set(&["fs:read:/src/**", "fs:read:{path}"]);
115 assert_eq!(
116 normalize(&g),
117 g,
118 "an undecided comparison must not drop a permission"
119 );
120 }
121
122 #[test]
123 fn an_undecidable_pair_keeps_both_atoms() {
124 let g = set(&["fs:read:/src/*.rs", "fs:read:/src/main.rs"]);
125 assert_eq!(normalize(&g).len(), 2);
126 }
127
128 #[test]
129 fn the_empty_set_normalises_to_itself() {
130 assert_eq!(normalize(&GrantSet::EMPTY), GrantSet::EMPTY);
131 assert!(is_normal(&GrantSet::EMPTY));
132 }
133
134 #[test]
135 fn read_and_write_of_the_same_path_both_survive() {
136 let g = set(&["fs:read:/src/**", "fs:write:/src/**"]);
138 assert_eq!(normalize(&g), g);
139 }
140
141 #[test]
142 fn a_chain_collapses_to_its_top() {
143 let g = set(&[
144 "fs:read:/**",
145 "fs:read:/src/**",
146 "fs:read:/src/lib/**",
147 "fs:read:/src/lib/a.rs",
148 ]);
149 assert_eq!(normalize(&g), set(&["fs:read:/**"]));
150 }
151
152 #[test]
153 fn ordering_is_deterministic() {
154 let a = set(&["fs:read:/b", "fs:read:/a", "fs:read:/c"]);
155 let b = set(&["fs:read:/c", "fs:read:/a", "fs:read:/b"]);
156 assert_eq!(normalize(&a), normalize(&b));
157 assert_eq!(
158 normalize(&a).iter().collect::<Vec<_>>(),
159 normalize(&b).iter().collect::<Vec<_>>()
160 );
161 }
162}