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, Default, PartialEq, Eq)]
56pub struct Clause {
57 pub operation: Option<Vec<Operation>>,
58 pub local_locus: Option<OrdBound<LocalLocus>>,
59 pub remote_reach: Option<OrdBound<RemoteReach>>,
60 pub remote_binding: Option<Vec<RemoteBinding>>,
61 pub provenance: Option<OrdBound<Provenance>>,
62 pub scale: Option<OrdBound<Scale>>,
63 pub retrieval: Option<OrdBound<RetrievalGranularity>>,
64 pub authority: Option<OrdBound<Authority>>,
65 pub isolation: Option<OrdBound<Isolation>>,
66 pub reversibility: Option<OrdBound<Reversibility>>,
67 pub persistence_level: Option<OrdBound<PersistenceLevel>>,
68 pub trigger_escape: Option<OrdBound<TriggerEscape>>,
69 pub trigger_kind: Option<Vec<TriggerKind>>,
70 pub disclosure_audience: Option<OrdBound<DisclosureAudience>>,
71 pub disclosure_channel: Option<Vec<Channel>>,
72 pub disclosure_principal: Option<Vec<Principal>>,
73 pub secret_level: Option<OrdBound<SecretLevel>>,
74 pub secret_channel: Option<Vec<Channel>>,
75 pub secret_principal: Option<Vec<Principal>>,
76 pub net_direction: Option<OrdBound<NetDirection>>,
77 pub net_destination: Option<OrdBound<NetDestination>>,
78 pub net_payload: Option<OrdBound<NetPayload>>,
79 pub execution_trust: Option<OrdBound<ExecutionTrust>>,
80 pub supply_source: Option<Vec<SupplySource>>,
81 pub pinning: Option<OrdBound<Pinning>>,
82 pub exec_surface: Option<Vec<ExecSurface>>,
83 pub cost: Option<OrdBound<Cost>>,
84}
85
86impl Clause {
87 pub fn admits(&self, cap: &Capability) -> bool {
89 self.check(cap, Role::Allow)
90 }
91
92 fn matches_as_deny(&self, cap: &Capability) -> bool {
99 self.check(cap, Role::Deny)
100 }
101
102 fn check(&self, cap: &Capability, role: Role) -> bool {
103 set_admits(self.operation.as_deref(), cap.operation)
104 && ord_admits(self.local_locus, cap.locus.local)
105 && ord_admits(self.remote_reach, cap.locus.remote)
106 && set_admits(self.remote_binding.as_deref(), cap.locus.binding)
107 && ord_admits(self.provenance, cap.locus.provenance)
108 && ord_admits(self.scale, cap.scale)
109 && ord_admits(self.retrieval, cap.retrieval)
110 && ord_admits(self.authority, cap.authority)
111 && ord_admits(self.isolation, cap.isolation)
112 && ord_admits(self.reversibility, cap.reversibility)
113 && ord_admits(self.persistence_level, cap.persistence.level)
114 && ord_admits(self.trigger_escape, cap.persistence.trigger.escape)
115 && set_admits(self.trigger_kind.as_deref(), cap.persistence.trigger.kind)
116 && ord_admits(self.disclosure_audience, cap.disclosure.audience)
117 && set_admits(self.disclosure_channel.as_deref(), cap.disclosure.channel)
118 && set_admits(self.disclosure_principal.as_deref(), cap.disclosure.principal)
119 && ord_admits(self.secret_level, cap.secret.level)
120 && set_admits(self.secret_channel.as_deref(), cap.secret.channel)
121 && set_admits(self.secret_principal.as_deref(), cap.secret.principal)
122 && ord_admits(self.net_direction, cap.network.direction)
123 && ord_admits(self.net_destination, cap.network.destination)
124 && ord_admits(self.net_payload, cap.network.payload)
125 && ord_admits(self.execution_trust, cap.execution.trust)
126 && self.supply_chain_admits(cap.execution.supply_chain, role)
127 && ord_admits(self.cost, cap.cost)
128 }
129
130 fn supply_chain_admits(&self, sc: Option<SupplyChain>, role: Role) -> bool {
135 match sc {
136 None => match role {
137 Role::Allow => true,
138 Role::Deny => {
139 self.supply_source.is_none()
140 && self.pinning.is_none()
141 && self.exec_surface.is_none()
142 }
143 },
144 Some(sc) => {
145 set_admits(self.supply_source.as_deref(), sc.source)
146 && ord_admits(self.pinning, sc.pinning)
147 && set_admits(self.exec_surface.as_deref(), sc.exec_surface)
148 }
149 }
150 }
151}
152
153#[derive(Copy, Clone, PartialEq, Eq)]
155enum Role {
156 Allow,
157 Deny,
158}
159
160#[derive(Clone, Debug, Default, PartialEq, Eq)]
163pub struct Level {
164 pub name: String,
165 pub allow: Vec<Clause>,
166 pub deny: Vec<Clause>,
167}
168
169impl Level {
170 pub fn new(name: impl Into<String>) -> Self {
173 Self { name: name.into(), allow: Vec::new(), deny: Vec::new() }
174 }
175
176 #[must_use]
178 pub fn allowing(mut self, clause: Clause) -> Self {
179 self.allow.push(clause);
180 self
181 }
182
183 #[must_use]
185 pub fn denying(mut self, clause: Clause) -> Self {
186 self.deny.push(clause);
187 self
188 }
189
190 pub fn admits_capability(&self, cap: &Capability) -> bool {
193 self.allow.iter().any(|c| c.admits(cap)) && !self.deny.iter().any(|c| c.matches_as_deny(cap))
194 }
195
196 pub fn admits(&self, profile: &Profile) -> bool {
199 profile.capabilities.iter().all(|c| self.admits_capability(c))
200 }
201
202 #[must_use]
208 pub fn extend(base: &Level, name: impl Into<String>, extra_allow: Vec<Clause>) -> Level {
209 let mut allow = base.allow.clone();
210 allow.extend(extra_allow);
211 Level { name: name.into(), allow, deny: base.deny.clone() }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use proptest::prelude::*;
219
220 fn cap(op: Operation) -> Capability {
221 Capability::new(op)
222 }
223
224 #[test]
225 fn empty_clause_admits_everything_empty_allow_admits_nothing() {
226 let all = Level::new("all").allowing(Clause::default());
227 let nothing = Level::new("nothing");
228 let destroy = Profile::of(vec![cap(Operation::Destroy)]);
229 assert!(all.admits(&destroy));
230 assert!(!nothing.admits(&destroy));
231 assert!(all.admits(&Profile::default()));
233 assert!(nothing.admits(&Profile::default()));
234 }
235
236 #[test]
237 fn read_local_admits_a_read_rejects_a_secret_and_a_destroy() {
238 let read_local = Level::new("read-local").allowing(Clause {
239 operation: Some(vec![Operation::Observe]),
240 local_locus: Some(OrdBound::at_most(LocalLocus::User)),
241 secret_level: Some(OrdBound::at_most(SecretLevel::UsesAmbient)),
242 net_direction: Some(OrdBound::at_most(NetDirection::Loopback)),
243 ..Default::default()
244 });
245
246 let plain_read = Profile::of(vec![{
247 let mut c = cap(Operation::Observe);
248 c.locus.local = LocalLocus::Worktree;
249 c
250 }]);
251 assert!(read_local.admits(&plain_read));
252
253 let secret_read = Profile::of(vec![{
254 let mut c = cap(Operation::Observe);
255 c.locus.local = LocalLocus::User;
256 c.secret.level = SecretLevel::Reads;
257 c
258 }]);
259 assert!(!read_local.admits(&secret_read), "cat ~/.ssh/id_rsa must not pass read-local");
260
261 let destroy = Profile::of(vec![cap(Operation::Destroy)]);
262 assert!(!read_local.admits(&destroy));
263 }
264
265 #[test]
266 fn yolo_deny_carves_out_the_catastrophe_corner() {
267 let yolo = Level::new("yolo").allowing(Clause::default()).denying(Clause {
268 operation: Some(vec![Operation::Destroy]),
269 reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
270 scale: Some(OrdBound::at_least(Scale::Unbounded)),
271 ..Default::default()
272 });
273
274 let bounded = Profile::of(vec![{
276 let mut c = cap(Operation::Destroy);
277 c.scale = Scale::Bounded;
278 c.reversibility = Reversibility::Recoverable;
279 c
280 }]);
281 assert!(yolo.admits(&bounded));
282
283 let wipe = Profile::of(vec![{
285 let mut c = cap(Operation::Destroy);
286 c.scale = Scale::Unbounded;
287 c.reversibility = Reversibility::Irreversible;
288 c
289 }]);
290 assert!(!yolo.admits(&wipe));
291 }
292
293 #[test]
294 fn supply_chain_gates_network_sourced_code_and_is_vacuous_otherwise() {
295 let dev = Level::new("dev").allowing(Clause {
298 execution_trust: Some(OrdBound::at_most(ExecutionTrust::NetworkSourced)),
299 supply_source: Some(vec![
300 SupplySource::PublicRegistry,
301 SupplySource::SignedRepo,
302 SupplySource::PrivateRegistry,
303 SupplySource::Vendored,
304 ]),
305 ..Default::default()
306 });
307
308 let build = Profile::of(vec![{
310 let mut c = cap(Operation::Execute);
311 c.execution = Execution {
312 trust: ExecutionTrust::NetworkSourced,
313 supply_chain: Some(SupplyChain {
314 source: SupplySource::PublicRegistry,
315 pinning: Pinning::HashVerified,
316 exec_surface: ExecSurface::BuildScript,
317 }),
318 };
319 c
320 }]);
321 assert!(dev.admits(&build), "cargo build from a registry");
322
323 let curl_sh = Profile::of(vec![{
325 let mut c = cap(Operation::Execute);
326 c.execution = Execution {
327 trust: ExecutionTrust::NetworkSourced,
328 supply_chain: Some(SupplyChain {
329 source: SupplySource::UnverifiedUrl,
330 pinning: Pinning::Floating,
331 exec_surface: ExecSurface::RunArtifact,
332 }),
333 };
334 c
335 }]);
336 assert!(!dev.admits(&curl_sh), "curl | sh is an unverified-url source");
337
338 let plain = Profile::of(vec![cap(Operation::Observe)]);
340 assert!(dev.admits(&plain), "a command with no supply chain passes vacuously");
341 }
342
343 #[test]
344 fn a_supply_chain_deny_does_not_match_a_capability_without_one() {
345 let level = Level::new("x").allowing(Clause::default()).denying(Clause {
348 supply_source: Some(vec![SupplySource::UnverifiedUrl]),
349 ..Default::default()
350 });
351
352 let plain = Profile::of(vec![cap(Operation::Observe)]);
353 assert!(level.admits(&plain), "a supply-chain deny must not match a no-supply-chain cap");
354
355 let curl_sh = Profile::of(vec![{
357 let mut c = cap(Operation::Execute);
358 c.execution = Execution {
359 trust: ExecutionTrust::NetworkSourced,
360 supply_chain: Some(SupplyChain {
361 source: SupplySource::UnverifiedUrl,
362 pinning: Pinning::Floating,
363 exec_surface: ExecSurface::RunArtifact,
364 }),
365 };
366 c
367 }]);
368 assert!(!level.admits(&curl_sh), "the deny corner still catches the unverified-url source");
369 }
370
371 #[test]
372 fn extend_inherits_deny_and_adds_allow() {
373 let base = Level::new("base")
374 .allowing(Clause { operation: Some(vec![Operation::Observe]), ..Default::default() })
375 .denying(Clause {
376 local_locus: Some(OrdBound::at_least(LocalLocus::Device)),
377 ..Default::default()
378 });
379 let child = Level::extend(
380 &base,
381 "child",
382 vec![Clause {
383 operation: Some(vec![Operation::Create, Operation::Mutate]),
384 ..Default::default()
385 }],
386 );
387
388 assert!(child.admits(&Profile::of(vec![cap(Operation::Create)])), "added allow");
389 assert!(child.admits(&Profile::of(vec![cap(Operation::Observe)])), "inherited allow");
390
391 let device = Profile::of(vec![{
392 let mut c = cap(Operation::Mutate);
393 c.locus.local = LocalLocus::Device;
394 c
395 }]);
396 assert!(!child.admits(&device), "inherited deny still bites");
397 }
398
399 use crate::engine::testgen::{arb_clause, arb_level, arb_profile};
403
404 proptest! {
405 #[test]
408 fn totality(level in arb_level(), profile in arb_profile()) {
409 let first = level.admits(&profile);
410 prop_assert_eq!(first, level.admits(&profile));
411 }
412
413 #[test]
416 fn extends_is_a_superset(
417 base in arb_level(),
418 extra in prop::collection::vec(arb_clause(), 0..3),
419 profile in arb_profile(),
420 ) {
421 let extended = Level::extend(&base, "child", extra);
422 prop_assert!(!base.admits(&profile) || extended.admits(&profile));
423 }
424
425 #[test]
428 fn deny_only_shrinks(
429 level in arb_level(),
430 extra_deny in arb_clause(),
431 profile in arb_profile(),
432 ) {
433 let stricter = level.clone().denying(extra_deny);
434 prop_assert!(!stricter.admits(&profile) || level.admits(&profile));
435 }
436 }
437}