1use std::collections::BTreeSet;
39
40use serde::{Deserialize, Serialize};
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum Scope<T: Ord + Clone> {
51 All,
53 Only(BTreeSet<T>),
55}
56
57impl<T: Ord + Clone> Scope<T> {
58 #[must_use]
60 pub fn top() -> Self {
61 Self::All
62 }
63
64 #[must_use]
66 pub fn none() -> Self {
67 Self::Only(BTreeSet::new())
68 }
69
70 pub fn only<I: IntoIterator<Item = T>>(items: I) -> Self {
72 Self::Only(items.into_iter().collect())
73 }
74
75 #[must_use]
77 pub fn leq(&self, other: &Self) -> bool {
78 match (self, other) {
79 (_, Self::All) => true,
81 (Self::All, Self::Only(_)) => false,
83 (Self::Only(a), Self::Only(b)) => a.is_subset(b),
85 }
86 }
87
88 #[must_use]
91 pub fn meet(&self, other: &Self) -> Self {
92 match (self, other) {
93 (Self::All, x) | (x, Self::All) => x.clone(),
94 (Self::Only(a), Self::Only(b)) => Self::Only(a.intersection(b).cloned().collect()),
95 }
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum CountBound {
106 Unlimited,
108 AtMost(u64),
110}
111
112impl CountBound {
113 #[must_use]
115 pub fn top() -> Self {
116 Self::Unlimited
117 }
118
119 #[must_use]
121 pub fn leq(&self, other: &Self) -> bool {
122 match (self, other) {
123 (_, Self::Unlimited) => true,
124 (Self::Unlimited, Self::AtMost(_)) => false,
125 (Self::AtMost(a), Self::AtMost(b)) => a <= b,
126 }
127 }
128
129 #[must_use]
131 pub fn meet(&self, other: &Self) -> Self {
132 match (self, other) {
133 (Self::Unlimited, x) | (x, Self::Unlimited) => *x,
134 (Self::AtMost(a), Self::AtMost(b)) => Self::AtMost((*a).min(*b)),
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct Caveats {
143 pub fs_read: Scope<String>,
145 pub fs_write: Scope<String>,
147 pub exec: Scope<String>,
149 pub net: Scope<String>,
151 pub max_calls: CountBound,
153 pub valid_for_generation: Scope<u64>,
156}
157
158impl Caveats {
159 #[must_use]
162 pub fn top() -> Self {
163 Self {
164 fs_read: Scope::top(),
165 fs_write: Scope::top(),
166 exec: Scope::top(),
167 net: Scope::top(),
168 max_calls: CountBound::top(),
169 valid_for_generation: Scope::top(),
170 }
171 }
172
173 #[must_use]
177 pub fn leq(&self, other: &Self) -> bool {
178 self.fs_read.leq(&other.fs_read)
179 && self.fs_write.leq(&other.fs_write)
180 && self.exec.leq(&other.exec)
181 && self.net.leq(&other.net)
182 && self.max_calls.leq(&other.max_calls)
183 && self.valid_for_generation.leq(&other.valid_for_generation)
184 }
185
186 #[must_use]
189 pub fn meet(&self, other: &Self) -> Self {
190 Self {
191 fs_read: self.fs_read.meet(&other.fs_read),
192 fs_write: self.fs_write.meet(&other.fs_write),
193 exec: self.exec.meet(&other.exec),
194 net: self.net.meet(&other.net),
195 max_calls: self.max_calls.meet(&other.max_calls),
196 valid_for_generation: self.valid_for_generation.meet(&other.valid_for_generation),
197 }
198 }
199}
200
201impl Default for Caveats {
202 fn default() -> Self {
205 Self::top()
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use proptest::prelude::*;
213
214 #[test]
217 fn scope_all_is_top() {
218 let bounded = Scope::only(["/a".to_string()]);
219 assert!(bounded.leq(&Scope::All));
220 assert!(!Scope::<String>::All.leq(&bounded));
221 assert_eq!(Scope::<String>::All.meet(&bounded), bounded);
222 }
223
224 #[test]
225 fn scope_subset_order() {
226 let small = Scope::only(["/a".to_string()]);
227 let big = Scope::only(["/a".to_string(), "/b".to_string()]);
228 assert!(small.leq(&big));
229 assert!(!big.leq(&small));
230 assert_eq!(big.meet(&small), small);
231 }
232
233 #[test]
234 fn scope_disjoint_meet_is_empty() {
235 let a = Scope::only(["/a".to_string()]);
236 let b = Scope::only(["/b".to_string()]);
237 assert_eq!(a.meet(&b), Scope::none());
238 assert!(Scope::<String>::none().leq(&a));
239 }
240
241 #[test]
242 fn count_bound_order_and_meet() {
243 assert!(CountBound::AtMost(3).leq(&CountBound::AtMost(5)));
244 assert!(!CountBound::AtMost(5).leq(&CountBound::AtMost(3)));
245 assert!(CountBound::AtMost(99).leq(&CountBound::Unlimited));
246 assert!(!CountBound::Unlimited.leq(&CountBound::AtMost(1)));
247 assert_eq!(
248 CountBound::AtMost(5).meet(&CountBound::AtMost(3)),
249 CountBound::AtMost(3)
250 );
251 assert_eq!(
252 CountBound::Unlimited.meet(&CountBound::AtMost(7)),
253 CountBound::AtMost(7)
254 );
255 }
256
257 #[test]
258 fn caveats_top_is_above_everything() {
259 let restricted = Caveats {
260 fs_read: Scope::only(["/repo".to_string()]),
261 fs_write: Scope::none(),
262 exec: Scope::only(["git".to_string()]),
263 net: Scope::none(),
264 max_calls: CountBound::AtMost(10),
265 valid_for_generation: Scope::only([7u64]),
266 };
267 assert!(restricted.leq(&Caveats::top()));
268 assert!(!Caveats::top().leq(&restricted));
269 }
270
271 #[test]
272 fn caveats_meet_attenuates_each_axis() {
273 let a = Caveats {
274 fs_read: Scope::only(["/repo".to_string(), "/tmp".to_string()]),
275 max_calls: CountBound::AtMost(10),
276 ..Caveats::top()
277 };
278 let b = Caveats {
279 fs_read: Scope::only(["/repo".to_string()]),
280 max_calls: CountBound::AtMost(4),
281 ..Caveats::top()
282 };
283 let m = a.meet(&b);
284 assert_eq!(m.fs_read, Scope::only(["/repo".to_string()]));
285 assert_eq!(m.max_calls, CountBound::AtMost(4));
286 assert!(m.leq(&a) && m.leq(&b));
287 }
288
289 #[test]
290 fn caveats_serde_roundtrip() {
291 let c = Caveats {
292 exec: Scope::only(["git".to_string(), "cargo".to_string()]),
293 max_calls: CountBound::AtMost(3),
294 valid_for_generation: Scope::only([42u64]),
295 ..Caveats::top()
296 };
297 let json = serde_json::to_string(&c).unwrap();
298 let back: Caveats = serde_json::from_str(&json).unwrap();
299 assert_eq!(c, back);
300 }
301
302 fn scope_str() -> impl Strategy<Value = Scope<String>> {
305 prop_oneof![
306 Just(Scope::All),
307 prop::collection::btree_set("[a-d]", 0..4).prop_map(Scope::Only),
308 ]
309 }
310
311 fn count_bound() -> impl Strategy<Value = CountBound> {
312 prop_oneof![
313 Just(CountBound::Unlimited),
314 (0u64..6).prop_map(CountBound::AtMost)
315 ]
316 }
317
318 fn gen_scope() -> impl Strategy<Value = Scope<u64>> {
319 prop_oneof![
320 Just(Scope::All),
321 prop::collection::btree_set(0u64..4, 0..4).prop_map(Scope::Only),
322 ]
323 }
324
325 prop_compose! {
326 fn caveats()(
327 fs_read in scope_str(),
328 fs_write in scope_str(),
329 exec in scope_str(),
330 net in scope_str(),
331 max_calls in count_bound(),
332 valid_for_generation in gen_scope(),
333 ) -> Caveats {
334 Caveats { fs_read, fs_write, exec, net, max_calls, valid_for_generation }
335 }
336 }
337
338 proptest! {
339 #[test]
341 fn leq_reflexive(a in caveats()) {
342 prop_assert!(a.leq(&a));
343 }
344
345 #[test]
346 fn leq_antisymmetric(a in caveats(), b in caveats()) {
347 if a.leq(&b) && b.leq(&a) {
348 prop_assert_eq!(a, b);
349 }
350 }
351
352 #[test]
353 fn leq_transitive(a in caveats(), b in caveats(), c in caveats()) {
354 if a.leq(&b) && b.leq(&c) {
355 prop_assert!(a.leq(&c));
356 }
357 }
358
359 #[test]
361 fn meet_is_lower_bound(a in caveats(), b in caveats()) {
362 let m = a.meet(&b);
363 prop_assert!(m.leq(&a), "meet must be ⊑ left");
364 prop_assert!(m.leq(&b), "meet must be ⊑ right");
365 }
366
367 #[test]
368 fn meet_is_greatest_lower_bound(a in caveats(), b in caveats(), c in caveats()) {
369 if c.leq(&a) && c.leq(&b) {
371 prop_assert!(c.leq(&a.meet(&b)));
372 }
373 }
374
375 #[test]
377 fn meet_commutative(a in caveats(), b in caveats()) {
378 prop_assert_eq!(a.meet(&b), b.meet(&a));
379 }
380
381 #[test]
382 fn meet_associative(a in caveats(), b in caveats(), c in caveats()) {
383 prop_assert_eq!(a.meet(&b).meet(&c), a.meet(&b.meet(&c)));
384 }
385
386 #[test]
387 fn meet_idempotent(a in caveats()) {
388 prop_assert_eq!(a.meet(&a), a.clone());
389 }
390
391 #[test]
392 fn top_is_meet_identity(a in caveats()) {
393 prop_assert_eq!(a.meet(&Caveats::top()), a.clone());
394 prop_assert!(a.leq(&Caveats::top()));
395 }
396
397 #[test]
401 fn meet_never_amplifies(a in caveats(), b in caveats()) {
402 let m = a.meet(&b);
403 prop_assert!(m.leq(&a) && m.leq(&b));
404 if a.leq(&m) {
406 prop_assert_eq!(&m, &a);
407 }
408 }
409 }
410}