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