1use super::facet::*;
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq)]
19pub struct OrdBound<T> {
20 pub min: Option<T>,
21 pub max: Option<T>,
22}
23
24impl<T: Ord + Copy> OrdBound<T> {
25 pub fn at_most(ceiling: T) -> Self {
27 Self { min: None, max: Some(ceiling) }
28 }
29 pub fn at_least(floor: T) -> Self {
31 Self { min: Some(floor), max: None }
32 }
33 pub fn exactly(exact: T) -> Self {
35 Self { min: Some(exact), max: Some(exact) }
36 }
37 pub fn admits(self, term: T) -> bool {
39 self.min.is_none_or(|lo| lo <= term) && self.max.is_none_or(|hi| term <= hi)
40 }
41}
42
43fn ord_admits<T: Ord + Copy>(bound: Option<OrdBound<T>>, term: T) -> bool {
44 bound.is_none_or(|b| b.admits(term))
45}
46
47fn set_admits<T: Eq + Copy>(set: Option<&[T]>, term: T) -> bool {
48 set.is_none_or(|s| s.contains(&term))
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct FacetMismatch {
59 pub facet: &'static str,
61 pub actual: &'static str,
63 pub bound: String,
65}
66
67impl std::fmt::Display for FacetMismatch {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 write!(f, "{} = {} (allowed: {})", self.facet, self.actual, self.bound)
70 }
71}
72
73fn ord_mismatch<T: Ord + Copy + FacetTerm>(
74 facet: &'static str,
75 bound: Option<OrdBound<T>>,
76 term: T,
77) -> Option<FacetMismatch> {
78 let b = bound?;
79 if b.admits(term) {
80 return None;
81 }
82 let bound = match (b.min, b.max) {
83 (None, Some(hi)) => format!("<= {}", hi.as_str()),
84 (Some(lo), None) => format!(">= {}", lo.as_str()),
85 (Some(lo), Some(hi)) => format!("{}..={}", lo.as_str(), hi.as_str()),
86 (None, None) => "any".to_string(),
87 };
88 Some(FacetMismatch { facet, actual: term.as_str(), bound })
89}
90
91fn set_mismatch<T: PartialEq + Copy + FacetTerm>(
92 facet: &'static str,
93 set: Option<&[T]>,
94 term: T,
95) -> Option<FacetMismatch> {
96 let s = set?;
97 if s.contains(&term) {
98 return None;
99 }
100 let bound = format!(
101 "one of [{}]",
102 s.iter().map(|t| t.as_str()).collect::<Vec<_>>().join(", "),
103 );
104 Some(FacetMismatch { facet, actual: term.as_str(), bound })
105}
106
107#[derive(Clone, Debug, Default, PartialEq, Eq)]
112pub struct Clause {
113 pub operation: Option<Vec<Operation>>,
114 pub local_locus: Option<OrdBound<LocalLocus>>,
115 pub remote_reach: Option<OrdBound<RemoteReach>>,
116 pub remote_binding: Option<Vec<RemoteBinding>>,
117 pub provenance: Option<OrdBound<Provenance>>,
118 pub scale: Option<OrdBound<Scale>>,
119 pub retrieval: Option<OrdBound<RetrievalGranularity>>,
120 pub authority: Option<OrdBound<Authority>>,
121 pub isolation: Option<OrdBound<Isolation>>,
122 pub reversibility: Option<OrdBound<Reversibility>>,
123 pub persistence_level: Option<OrdBound<PersistenceLevel>>,
124 pub trigger_escape: Option<OrdBound<TriggerEscape>>,
125 pub trigger_kind: Option<Vec<TriggerKind>>,
126 pub disclosure_audience: Option<OrdBound<DisclosureAudience>>,
127 pub disclosure_channel: Option<Vec<Channel>>,
128 pub disclosure_principal: Option<Vec<Principal>>,
129 pub secret_level: Option<OrdBound<SecretLevel>>,
130 pub secret_channel: Option<Vec<Channel>>,
131 pub secret_principal: Option<Vec<Principal>>,
132 pub net_direction: Option<OrdBound<NetDirection>>,
133 pub net_destination: Option<OrdBound<NetDestination>>,
134 pub net_payload: Option<OrdBound<NetPayload>>,
135 pub execution_trust: Option<OrdBound<ExecutionTrust>>,
136 pub supply_source: Option<Vec<SupplySource>>,
137 pub pinning: Option<OrdBound<Pinning>>,
138 pub exec_surface: Option<Vec<ExecSurface>>,
139 pub cost: Option<OrdBound<Cost>>,
140}
141
142impl Clause {
143 pub fn admits(&self, cap: &Capability) -> bool {
145 self.check(cap, Role::Allow)
146 }
147
148 fn matches_as_deny(&self, cap: &Capability) -> bool {
155 self.check(cap, Role::Deny)
156 }
157
158 fn check(&self, cap: &Capability, role: Role) -> bool {
159 self.first_mismatch(cap, role).is_none()
160 }
161
162 #[cfg(test)]
175 pub(crate) fn first_mismatch_for_test(
176 &self,
177 cap: &Capability,
178 deny_role: bool,
179 ) -> Option<FacetMismatch> {
180 self.first_mismatch(cap, if deny_role { Role::Deny } else { Role::Allow })
181 }
182
183 #[cfg(test)]
184 pub(crate) fn matches_as_deny_for_test(&self, cap: &Capability) -> bool {
185 self.matches_as_deny(cap)
186 }
187
188 fn first_mismatch(&self, cap: &Capability, role: Role) -> Option<FacetMismatch> {
189 set_mismatch("operation", self.operation.as_deref(), cap.operation)
190 .or_else(|| ord_mismatch("locus.local", self.local_locus, cap.locus.local))
191 .or_else(|| ord_mismatch("locus.remote", self.remote_reach, cap.locus.remote))
192 .or_else(|| set_mismatch("locus.binding", self.remote_binding.as_deref(), cap.locus.binding))
193 .or_else(|| ord_mismatch("locus.provenance", self.provenance, cap.locus.provenance))
194 .or_else(|| ord_mismatch("scale", self.scale, cap.scale))
195 .or_else(|| ord_mismatch("retrieval", self.retrieval, cap.retrieval))
196 .or_else(|| ord_mismatch("authority", self.authority, cap.authority))
197 .or_else(|| ord_mismatch("isolation", self.isolation, cap.isolation))
198 .or_else(|| ord_mismatch("reversibility", self.reversibility, cap.reversibility))
199 .or_else(|| ord_mismatch("persistence.level", self.persistence_level, cap.persistence.level))
200 .or_else(|| ord_mismatch("persistence.trigger.escape", self.trigger_escape, cap.persistence.trigger.escape))
201 .or_else(|| set_mismatch("persistence.trigger.kind", self.trigger_kind.as_deref(), cap.persistence.trigger.kind))
202 .or_else(|| ord_mismatch("disclosure.audience", self.disclosure_audience, cap.disclosure.audience))
203 .or_else(|| set_mismatch("disclosure.channel", self.disclosure_channel.as_deref(), cap.disclosure.channel))
204 .or_else(|| set_mismatch("disclosure.principal", self.disclosure_principal.as_deref(), cap.disclosure.principal))
205 .or_else(|| ord_mismatch("secret.level", self.secret_level, cap.secret.level))
206 .or_else(|| set_mismatch("secret.channel", self.secret_channel.as_deref(), cap.secret.channel))
207 .or_else(|| set_mismatch("secret.principal", self.secret_principal.as_deref(), cap.secret.principal))
208 .or_else(|| ord_mismatch("network.direction", self.net_direction, cap.network.direction))
209 .or_else(|| ord_mismatch("network.destination", self.net_destination, cap.network.destination))
210 .or_else(|| ord_mismatch("network.payload", self.net_payload, cap.network.payload))
211 .or_else(|| ord_mismatch("execution.trust", self.execution_trust, cap.execution.trust))
212 .or_else(|| self.supply_chain_mismatch(cap.execution.supply_chain, role))
213 .or_else(|| ord_mismatch("cost", self.cost, cap.cost))
214 }
215
216 fn supply_chain_mismatch(&self, sc: Option<SupplyChain>, role: Role) -> Option<FacetMismatch> {
221 if self.supply_chain_admits(sc, role) {
222 return None;
223 }
224 let Some(sc) = sc else {
225 return Some(FacetMismatch {
229 facet: "execution.supply_chain",
230 actual: "absent",
231 bound: "this clause constrains the supply chain, which this capability has none of"
232 .to_string(),
233 });
234 };
235 set_mismatch("supply_chain.source", self.supply_source.as_deref(), sc.source)
236 .or_else(|| ord_mismatch("supply_chain.pinning", self.pinning, sc.pinning))
237 .or_else(|| set_mismatch("supply_chain.exec_surface", self.exec_surface.as_deref(), sc.exec_surface))
238 }
239
240 fn supply_chain_admits(&self, sc: Option<SupplyChain>, role: Role) -> bool {
241 match sc {
242 None => match role {
243 Role::Allow => true,
244 Role::Deny => {
245 self.supply_source.is_none()
246 && self.pinning.is_none()
247 && self.exec_surface.is_none()
248 }
249 },
250 Some(sc) => {
251 set_admits(self.supply_source.as_deref(), sc.source)
252 && ord_admits(self.pinning, sc.pinning)
253 && set_admits(self.exec_surface.as_deref(), sc.exec_surface)
254 }
255 }
256 }
257}
258
259#[derive(Copy, Clone, PartialEq, Eq)]
261enum Role {
262 Allow,
263 Deny,
264}
265
266#[derive(Clone, Debug, Default, PartialEq, Eq)]
269pub struct Level {
270 pub name: String,
271 pub allow: Vec<Clause>,
272 pub deny: Vec<Clause>,
273}
274
275impl Level {
276 pub fn new(name: impl Into<String>) -> Self {
279 Self { name: name.into(), allow: Vec::new(), deny: Vec::new() }
280 }
281
282 #[must_use]
284 pub fn allowing(mut self, clause: Clause) -> Self {
285 self.allow.push(clause);
286 self
287 }
288
289 #[must_use]
291 pub fn denying(mut self, clause: Clause) -> Self {
292 self.deny.push(clause);
293 self
294 }
295
296 pub fn nearest_miss(&self, cap: &Capability) -> Option<FacetMismatch> {
307 if self.allow.iter().any(|c| c.admits(cap)) {
308 return self.deny.iter().find(|c| c.matches_as_deny(cap)).map(|_| FacetMismatch {
310 facet: "deny-clause",
311 actual: "matched",
312 bound: "removed by an explicit deny clause".to_string(),
313 });
314 }
315 if self.allow.is_empty() {
316 return Some(FacetMismatch {
317 facet: "level",
318 actual: "any capability",
319 bound: "nothing — this level declares no allow clause".to_string(),
320 });
321 }
322 let on_topic = |c: &&Clause| {
323 c.operation.as_ref().is_none_or(|ops| ops.contains(&cap.operation))
324 };
325 self.allow
326 .iter()
327 .filter(on_topic)
328 .chain(self.allow.iter())
329 .find_map(|c| c.first_mismatch(cap, Role::Allow))
330 }
331
332 pub fn admits_capability(&self, cap: &Capability) -> bool {
333 self.allow.iter().any(|c| c.admits(cap)) && !self.deny.iter().any(|c| c.matches_as_deny(cap))
334 }
335
336 pub fn admits(&self, profile: &Profile) -> bool {
339 profile.capabilities.iter().all(|c| self.admits_capability(c))
340 }
341
342 #[must_use]
348 pub fn extend(base: &Level, name: impl Into<String>, extra_allow: Vec<Clause>) -> Level {
349 let mut allow = base.allow.clone();
350 allow.extend(extra_allow);
351 Level { name: name.into(), allow, deny: base.deny.clone() }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use proptest::prelude::*;
359
360 fn cap(op: Operation) -> Capability {
361 Capability::new(op)
362 }
363
364 #[test]
365 fn empty_clause_admits_everything_empty_allow_admits_nothing() {
366 let all = Level::new("all").allowing(Clause::default());
367 let nothing = Level::new("nothing");
368 let destroy = Profile::of(vec![cap(Operation::Destroy)]);
369 assert!(all.admits(&destroy));
370 assert!(!nothing.admits(&destroy));
371 assert!(all.admits(&Profile::default()));
373 assert!(nothing.admits(&Profile::default()));
374 }
375
376 #[test]
377 fn read_local_admits_a_read_rejects_a_secret_and_a_destroy() {
378 let read_local = Level::new("read-local").allowing(Clause {
379 operation: Some(vec![Operation::Observe]),
380 local_locus: Some(OrdBound::at_most(LocalLocus::User)),
381 secret_level: Some(OrdBound::at_most(SecretLevel::UsesAmbient)),
382 net_direction: Some(OrdBound::at_most(NetDirection::Loopback)),
383 ..Default::default()
384 });
385
386 let plain_read = Profile::of(vec![{
387 let mut c = cap(Operation::Observe);
388 c.locus.local = LocalLocus::Worktree;
389 c
390 }]);
391 assert!(read_local.admits(&plain_read));
392
393 let secret_read = Profile::of(vec![{
394 let mut c = cap(Operation::Observe);
395 c.locus.local = LocalLocus::User;
396 c.secret.level = SecretLevel::Reads;
397 c
398 }]);
399 assert!(!read_local.admits(&secret_read), "cat ~/.ssh/id_rsa must not pass read-local");
400
401 let destroy = Profile::of(vec![cap(Operation::Destroy)]);
402 assert!(!read_local.admits(&destroy));
403 }
404
405 #[test]
406 fn yolo_deny_carves_out_the_catastrophe_corner() {
407 let yolo = Level::new("yolo").allowing(Clause::default()).denying(Clause {
408 operation: Some(vec![Operation::Destroy]),
409 reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
410 scale: Some(OrdBound::at_least(Scale::Unbounded)),
411 ..Default::default()
412 });
413
414 let bounded = Profile::of(vec![{
416 let mut c = cap(Operation::Destroy);
417 c.scale = Scale::Bounded;
418 c.reversibility = Reversibility::Recoverable;
419 c
420 }]);
421 assert!(yolo.admits(&bounded));
422
423 let wipe = Profile::of(vec![{
425 let mut c = cap(Operation::Destroy);
426 c.scale = Scale::Unbounded;
427 c.reversibility = Reversibility::Irreversible;
428 c
429 }]);
430 assert!(!yolo.admits(&wipe));
431 }
432
433 #[test]
434 fn supply_chain_gates_network_sourced_code_and_is_vacuous_otherwise() {
435 let dev = Level::new("dev").allowing(Clause {
438 execution_trust: Some(OrdBound::at_most(ExecutionTrust::NetworkSourced)),
439 supply_source: Some(vec![
440 SupplySource::PublicRegistry,
441 SupplySource::SignedRepo,
442 SupplySource::PrivateRegistry,
443 SupplySource::Vendored,
444 ]),
445 ..Default::default()
446 });
447
448 let build = Profile::of(vec![{
450 let mut c = cap(Operation::Execute);
451 c.execution = Execution {
452 trust: ExecutionTrust::NetworkSourced,
453 supply_chain: Some(SupplyChain {
454 source: SupplySource::PublicRegistry,
455 pinning: Pinning::HashVerified,
456 exec_surface: ExecSurface::BuildScript,
457 }),
458 };
459 c
460 }]);
461 assert!(dev.admits(&build), "cargo build from a registry");
462
463 let curl_sh = Profile::of(vec![{
465 let mut c = cap(Operation::Execute);
466 c.execution = Execution {
467 trust: ExecutionTrust::NetworkSourced,
468 supply_chain: Some(SupplyChain {
469 source: SupplySource::UnverifiedUrl,
470 pinning: Pinning::Floating,
471 exec_surface: ExecSurface::RunArtifact,
472 }),
473 };
474 c
475 }]);
476 assert!(!dev.admits(&curl_sh), "curl | sh is an unverified-url source");
477
478 let plain = Profile::of(vec![cap(Operation::Observe)]);
480 assert!(dev.admits(&plain), "a command with no supply chain passes vacuously");
481 }
482
483 #[test]
484 fn a_supply_chain_deny_does_not_match_a_capability_without_one() {
485 let level = Level::new("x").allowing(Clause::default()).denying(Clause {
488 supply_source: Some(vec![SupplySource::UnverifiedUrl]),
489 ..Default::default()
490 });
491
492 let plain = Profile::of(vec![cap(Operation::Observe)]);
493 assert!(level.admits(&plain), "a supply-chain deny must not match a no-supply-chain cap");
494
495 let curl_sh = Profile::of(vec![{
497 let mut c = cap(Operation::Execute);
498 c.execution = Execution {
499 trust: ExecutionTrust::NetworkSourced,
500 supply_chain: Some(SupplyChain {
501 source: SupplySource::UnverifiedUrl,
502 pinning: Pinning::Floating,
503 exec_surface: ExecSurface::RunArtifact,
504 }),
505 };
506 c
507 }]);
508 assert!(!level.admits(&curl_sh), "the deny corner still catches the unverified-url source");
509 }
510
511 #[test]
512 fn extend_inherits_deny_and_adds_allow() {
513 let base = Level::new("base")
514 .allowing(Clause { operation: Some(vec![Operation::Observe]), ..Default::default() })
515 .denying(Clause {
516 local_locus: Some(OrdBound::at_least(LocalLocus::Device)),
517 ..Default::default()
518 });
519 let child = Level::extend(
520 &base,
521 "child",
522 vec![Clause {
523 operation: Some(vec![Operation::Create, Operation::Mutate]),
524 ..Default::default()
525 }],
526 );
527
528 assert!(child.admits(&Profile::of(vec![cap(Operation::Create)])), "added allow");
529 assert!(child.admits(&Profile::of(vec![cap(Operation::Observe)])), "inherited allow");
530
531 let device = Profile::of(vec![{
532 let mut c = cap(Operation::Mutate);
533 c.locus.local = LocalLocus::Device;
534 c
535 }]);
536 assert!(!child.admits(&device), "inherited deny still bites");
537 }
538
539 use crate::engine::testgen::{arb_clause, arb_level, arb_profile};
543
544 proptest! {
545 #[test]
548 fn totality(level in arb_level(), profile in arb_profile()) {
549 let first = level.admits(&profile);
550 prop_assert_eq!(first, level.admits(&profile));
551 }
552
553 #[test]
556 fn extends_is_a_superset(
557 base in arb_level(),
558 extra in prop::collection::vec(arb_clause(), 0..3),
559 profile in arb_profile(),
560 ) {
561 let extended = Level::extend(&base, "child", extra);
562 prop_assert!(!base.admits(&profile) || extended.admits(&profile));
563 }
564
565 #[test]
568 fn deny_only_shrinks(
569 level in arb_level(),
570 extra_deny in arb_clause(),
571 profile in arb_profile(),
572 ) {
573 let stricter = level.clone().denying(extra_deny);
574 prop_assert!(!stricter.admits(&profile) || level.admits(&profile));
575 }
576 }
577}