1use murmur_artifact::ContainmentClass;
30use serde::Serialize;
31
32use crate::{
33 errors::RuntimeError,
34 sandbox::{detect_enforcement_tier, EnforcementTier},
35 sealed::SealedBlocker,
36 types::CapabilityPolicy,
37};
38
39pub use crate::sandbox::detect_sealed_blocker;
40
41pub(crate) fn achieved_class_for_tier(tier: EnforcementTier) -> ContainmentClass {
47 match tier {
48 EnforcementTier::KernelSealed => ContainmentClass::Sealed,
53 EnforcementTier::KernelFull => ContainmentClass::Scoped,
56 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
60 ContainmentClass::Advisory
61 }
62 }
63}
64
65pub(crate) fn achieved_containment_class(
79 tier: EnforcementTier,
80 workdir_exec: bool,
81) -> ContainmentClass {
82 let host_ceiling = achieved_class_for_tier(tier);
83 if workdir_exec {
84 host_ceiling.min(ContainmentClass::Advisory)
85 } else {
86 host_ceiling
87 }
88}
89
90pub fn detect_achieved_containment() -> ContainmentClass {
98 achieved_class_for_tier(detect_enforcement_tier())
99}
100
101pub fn containment_shortfall_reason(
115 declared: ContainmentClass,
116 achieved: ContainmentClass,
117 sealed_blocker: Option<SealedBlocker>,
118 workdir_exec: bool,
119) -> Option<String> {
120 if achieved >= declared {
121 return None;
122 }
123
124 if workdir_exec && declared > ContainmentClass::Advisory {
125 return Some(WORKDIR_EXEC_SHORTFALL.to_string());
126 }
127
128 let reason = match declared {
129 ContainmentClass::Advisory => {
133 "advisory is the weakest containment class and is satisfied by every host".to_string()
134 }
135 ContainmentClass::Scoped => {
136 "scoped requires Landlock filesystem mediation (Linux 5.13+ with a usable Landlock \
137 ABI); this host provides no kernel filesystem mediation, so paths outside the \
138 workdir are constrained by convention only"
139 .to_string()
140 }
141 ContainmentClass::Sealed => match sealed_blocker {
144 Some(blocker) => blocker.reason(),
145 None => "sealed requires a private mount namespace pivoted onto a composed root \
146 (unshare(CLONE_NEWUSER|CLONE_NEWNS) + pivot_root), which this host did not \
147 provide"
148 .to_string(),
149 },
150 };
151
152 Some(reason)
153}
154
155const WORKDIR_EXEC_SHORTFALL: &str =
160 "capabilities.filesystem.workdir_exec: true keeps the Landlock Execute right on the session \
161 workdir, so a binary the capsule compiles, downloads or renames inside it runs regardless of \
162 capabilities.shell.allow — the allowlist stops being an enforceable property of this capsule. \
163 No host can back a class above advisory for it. Either remove workdir_exec (the allowlist is \
164 then enforced by the kernel on the resolved path) or lower the declared containment floor to \
165 advisory";
166
167pub fn check_containment_floor(
173 declared: ContainmentClass,
174 achieved: ContainmentClass,
175 sealed_blocker: Option<SealedBlocker>,
176 workdir_exec: bool,
177) -> Result<(), RuntimeError> {
178 match containment_shortfall_reason(declared, achieved, sealed_blocker, workdir_exec) {
179 None => Ok(()),
180 Some(reason) => Err(RuntimeError::ContainmentFloorUnmet {
181 declared,
182 achieved,
183 reason,
184 }),
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
194pub struct ScopeReport {
195 pub declared_containment: ContainmentClass,
197 pub achieved_containment: ContainmentClass,
199 pub floor_met: bool,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub shortfall_reason: Option<String>,
204 pub enforcement_tier: &'static str,
206 pub filesystem_scope: Option<String>,
208 pub workdir_exec: bool,
216 pub network_allow: Vec<String>,
218 pub unix_sockets: bool,
220 pub shell_allow: Vec<String>,
222 pub spawn_allow: Vec<String>,
224 pub env_allow: Vec<String>,
226 pub interpreter_runtime_grants: Vec<String>,
230 pub staged_runtime_grants: Vec<String>,
239}
240
241impl ScopeReport {
242 pub fn render(&self) -> String {
244 let mut out = String::new();
245 out.push_str("Containment\n");
246 out.push_str(&format!(" declared: {}\n", self.declared_containment));
247 out.push_str(&format!(" achieved: {}\n", self.achieved_containment));
248 out.push_str(&format!(
249 " floor met: {}\n",
250 if self.floor_met { "yes" } else { "no" }
251 ));
252 if let Some(reason) = &self.shortfall_reason {
253 out.push_str(&format!(" reason: {reason}\n"));
254 }
255 out.push_str(&format!(" mechanism: {}\n", self.enforcement_tier));
256
257 out.push_str("\nEffective grants\n");
258 out.push_str(&format!(
259 " filesystem scope: {}\n",
260 self.filesystem_scope.as_deref().unwrap_or("<none>")
261 ));
262 out.push_str(&format!(" workdir exec: {}\n", self.workdir_exec));
263 push_list(&mut out, "network allow", &self.network_allow);
264 out.push_str(&format!(" unix sockets: {}\n", self.unix_sockets));
265 push_list(&mut out, "shell allow", &self.shell_allow);
266 push_list(&mut out, "spawn allow", &self.spawn_allow);
267 push_list(&mut out, "env allow", &self.env_allow);
268 push_list(
269 &mut out,
270 "interpreter runtime",
271 &self.interpreter_runtime_grants,
272 );
273 push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
274
275 if !self.floor_met {
276 out.push_str(
277 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
278 launch here.\n",
279 );
280 }
281 out
282 }
283}
284
285fn push_list(out: &mut String, label: &str, values: &[String]) {
286 if values.is_empty() {
287 out.push_str(&format!(" {label}: <none>\n"));
288 return;
289 }
290 out.push_str(&format!(" {label}:\n"));
291 for value in values {
292 out.push_str(&format!(" - {value}\n"));
293 }
294}
295
296pub fn explain_scope(policy: &CapabilityPolicy, declared: ContainmentClass) -> ScopeReport {
299 scope_report_for_tier(
300 policy,
301 declared,
302 detect_enforcement_tier(),
303 detect_sealed_blocker(),
304 )
305}
306
307pub(crate) fn scope_report_for_tier(
310 policy: &CapabilityPolicy,
311 declared: ContainmentClass,
312 tier: EnforcementTier,
313 sealed_blocker: Option<SealedBlocker>,
314) -> ScopeReport {
315 let achieved = achieved_containment_class(tier, policy.workdir_exec_allowed);
316 let shortfall_reason = containment_shortfall_reason(
317 declared,
318 achieved,
319 sealed_blocker,
320 policy.workdir_exec_allowed,
321 );
322
323 ScopeReport {
324 declared_containment: declared,
325 achieved_containment: achieved,
326 floor_met: shortfall_reason.is_none(),
327 shortfall_reason,
328 enforcement_tier: enforcement_tier_name(tier),
329 filesystem_scope: policy.filesystem_scope.clone(),
330 workdir_exec: policy.workdir_exec_allowed,
331 network_allow: policy.network_allow.clone(),
332 unix_sockets: policy.unix_sockets_allowed,
333 shell_allow: policy.shell_allow.clone(),
334 spawn_allow: policy.spawn_allow.clone(),
335 env_allow: policy.env_allow.clone(),
336 interpreter_runtime_grants: policy
337 .shell_interpreter_runtime
338 .iter()
339 .flat_map(|grant| {
340 grant.dirs.iter().map(move |dir| {
341 let listable = if dir.list_dir { " (list_dir)" } else { "" };
342 format!("{}: {}{}", grant.binary, dir.path, listable)
343 })
344 })
345 .collect(),
346 staged_runtime_grants: policy
347 .shell_staged_runtime
348 .iter()
349 .map(|grant| {
350 format!(
351 "{}: {} (pin: {})",
352 grant.binary, grant.source_path, grant.pin
353 )
354 })
355 .collect(),
356 }
357}
358
359fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
362 match tier {
363 EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
364 EnforcementTier::KernelFull => "landlock+seccomp",
365 EnforcementTier::KernelSeccompOnly => "seccomp-only",
366 EnforcementTier::EnvironmentOnly => "none",
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
374
375 const ALL_TIERS: &[EnforcementTier] = &[
377 EnforcementTier::KernelSealed,
378 EnforcementTier::KernelFull,
379 EnforcementTier::KernelSeccompOnly,
380 EnforcementTier::EnvironmentOnly,
381 ];
382
383 #[test]
384 fn kernel_full_achieves_scoped() {
385 assert_eq!(
386 achieved_class_for_tier(EnforcementTier::KernelFull),
387 ContainmentClass::Scoped
388 );
389 }
390
391 #[test]
393 fn only_the_sealed_tier_achieves_sealed() {
394 assert_eq!(
395 achieved_class_for_tier(EnforcementTier::KernelSealed),
396 ContainmentClass::Sealed
397 );
398 for tier in ALL_TIERS
399 .iter()
400 .filter(|t| **t != EnforcementTier::KernelSealed)
401 {
402 assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
403 }
404 }
405
406 #[test]
407 fn tiers_without_landlock_achieve_only_advisory() {
408 assert_eq!(
409 achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
410 ContainmentClass::Advisory
411 );
412 assert_eq!(
413 achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
414 ContainmentClass::Advisory
415 );
416 }
417
418 #[test]
424 fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
425 assert_eq!(
427 achieved_containment_class(EnforcementTier::KernelSealed, false),
428 ContainmentClass::Sealed
429 );
430 assert_eq!(
431 achieved_containment_class(EnforcementTier::KernelFull, false),
432 ContainmentClass::Scoped
433 );
434 assert_eq!(
435 achieved_containment_class(EnforcementTier::KernelSeccompOnly, false),
436 ContainmentClass::Advisory
437 );
438 assert_eq!(
439 achieved_containment_class(EnforcementTier::EnvironmentOnly, false),
440 ContainmentClass::Advisory
441 );
442
443 for tier in ALL_TIERS {
445 assert_eq!(
446 achieved_containment_class(*tier, true),
447 ContainmentClass::Advisory,
448 "workdir_exec must cap tier {tier:?} at advisory"
449 );
450 }
451 }
452
453 #[test]
456 fn the_tier_only_mapping_is_unaffected_by_the_cap() {
457 for tier in ALL_TIERS {
458 assert_eq!(
459 achieved_class_for_tier(*tier),
460 achieved_containment_class(*tier, false),
461 "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
462 );
463 }
464 }
465
466 #[test]
470 fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
471 let achieved = achieved_containment_class(EnforcementTier::KernelFull, true);
472 assert_eq!(achieved, ContainmentClass::Advisory);
473
474 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true)
475 .expect_err("workdir_exec + scoped must refuse");
476 match error {
477 RuntimeError::ContainmentFloorUnmet {
478 declared,
479 achieved,
480 reason,
481 } => {
482 assert_eq!(declared, ContainmentClass::Scoped);
483 assert_eq!(achieved, ContainmentClass::Advisory);
484 assert!(
485 reason.contains("workdir_exec"),
486 "the reason must name the manifest key, got: {reason}"
487 );
488 assert!(
489 !reason.contains("this host provides no kernel filesystem mediation"),
490 "the reason must not blame the host for a manifest declaration: {reason}"
491 );
492 }
493 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
494 }
495 }
496
497 #[test]
501 fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
502 let reason = containment_shortfall_reason(
503 ContainmentClass::Sealed,
504 ContainmentClass::Advisory,
505 Some(SealedBlocker::AppArmorProfileMissing),
506 true,
507 )
508 .expect("refusal");
509 assert!(reason.contains("workdir_exec"));
510 assert!(!reason.contains("apparmor_parser"));
511 }
512
513 #[test]
516 fn workdir_exec_alone_refuses_nothing() {
517 for tier in ALL_TIERS {
518 let achieved = achieved_containment_class(*tier, true);
519 assert!(
520 check_containment_floor(ContainmentClass::Advisory, achieved, None, true).is_ok(),
521 "workdir_exec at an advisory floor must launch on tier {tier:?}"
522 );
523 }
524 }
525
526 #[test]
527 fn advisory_floor_is_met_on_every_tier() {
528 for tier in ALL_TIERS {
529 let achieved = achieved_class_for_tier(*tier);
530 assert!(
531 check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok()
532 );
533 }
534 }
535
536 #[test]
537 fn scoped_floor_is_met_on_every_landlock_capable_tier() {
538 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
539 let achieved = achieved_class_for_tier(tier);
540 assert!(
541 check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok()
542 );
543 }
544
545 for tier in [
546 EnforcementTier::KernelSeccompOnly,
547 EnforcementTier::EnvironmentOnly,
548 ] {
549 let achieved = achieved_class_for_tier(tier);
550 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, false)
551 .expect_err("scoped must refuse without Landlock");
552 match error {
553 RuntimeError::ContainmentFloorUnmet {
554 declared,
555 achieved,
556 reason,
557 } => {
558 assert_eq!(declared, ContainmentClass::Scoped);
559 assert_eq!(achieved, ContainmentClass::Advisory);
560 assert!(
561 reason.contains("Landlock"),
562 "reason must name the missing mechanism, got: {reason}"
563 );
564 }
565 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
566 }
567 }
568 }
569
570 #[test]
576 fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
577 let achieved = achieved_class_for_tier(EnforcementTier::KernelSealed);
578 assert_eq!(achieved, ContainmentClass::Sealed);
579 assert!(check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok());
580 assert!(check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok());
581 assert_eq!(
582 containment_shortfall_reason(ContainmentClass::Scoped, achieved, None, false),
583 None
584 );
585 }
586
587 #[test]
588 fn sealed_floor_is_met_only_on_the_sealed_tier() {
589 assert!(check_containment_floor(
590 ContainmentClass::Sealed,
591 achieved_class_for_tier(EnforcementTier::KernelSealed),
592 None,
593 false
594 )
595 .is_ok());
596
597 for tier in ALL_TIERS
598 .iter()
599 .filter(|t| **t != EnforcementTier::KernelSealed)
600 {
601 let achieved = achieved_class_for_tier(*tier);
602 let error = check_containment_floor(
603 ContainmentClass::Sealed,
604 achieved,
605 Some(SealedBlocker::NamespaceCreationDenied),
606 false,
607 )
608 .expect_err("sealed must refuse on a host that cannot back it");
609 match error {
610 RuntimeError::ContainmentFloorUnmet {
611 declared, reason, ..
612 } => {
613 assert_eq!(declared, ContainmentClass::Sealed);
614 assert!(
615 reason.contains("--cap-add SYS_ADMIN"),
616 "reason must name the blocking mechanism's remediation, got: {reason}"
617 );
618 }
619 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
620 }
621 }
622 }
623
624 #[test]
627 fn the_sealed_refusal_names_the_specific_blocker() {
628 let achieved = ContainmentClass::Scoped;
629
630 let apparmor = containment_shortfall_reason(
631 ContainmentClass::Sealed,
632 achieved,
633 Some(SealedBlocker::AppArmorProfileMissing),
634 false,
635 )
636 .expect("refusal");
637 assert!(apparmor.contains("mur-sealed"));
638 assert!(apparmor.contains("apparmor_parser -r"));
639 assert!(!apparmor.contains("--cap-add"));
640
641 let container = containment_shortfall_reason(
642 ContainmentClass::Sealed,
643 achieved,
644 Some(SealedBlocker::NamespaceCreationDenied),
645 false,
646 )
647 .expect("refusal");
648 assert!(container.contains("--cap-add SYS_ADMIN"));
649 assert!(container.contains("outside the container"));
650 assert!(!container.contains("apparmor_parser"));
651
652 for blocker in [
654 SealedBlocker::NotLinux,
655 SealedBlocker::AppArmorProfileMissing,
656 SealedBlocker::NamespaceCreationDenied,
657 SealedBlocker::MountDenied,
658 SealedBlocker::KernelUnsupported,
659 SealedBlocker::LandlockUnavailable,
660 ] {
661 let reason = containment_shortfall_reason(
662 ContainmentClass::Sealed,
663 achieved,
664 Some(blocker),
665 false,
666 )
667 .expect("refusal");
668 assert!(
669 !reason.contains("no host can provide it today"),
670 "got: {reason}"
671 );
672 }
673 }
674
675 #[test]
678 fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
679 let reason = containment_shortfall_reason(
680 ContainmentClass::Sealed,
681 ContainmentClass::Advisory,
682 None,
683 false,
684 )
685 .expect("refusal");
686 assert!(reason.contains("pivot_root"));
687 assert!(reason.contains("CLONE_NEWNS"));
688 }
689
690 #[test]
691 fn shortfall_reason_is_absent_when_the_floor_is_met() {
692 assert_eq!(
693 containment_shortfall_reason(
694 ContainmentClass::Advisory,
695 ContainmentClass::Scoped,
696 None,
697 false
698 ),
699 None
700 );
701 assert_eq!(
702 containment_shortfall_reason(
703 ContainmentClass::Scoped,
704 ContainmentClass::Scoped,
705 None,
706 false
707 ),
708 None
709 );
710 assert_eq!(
711 containment_shortfall_reason(
712 ContainmentClass::Sealed,
713 ContainmentClass::Sealed,
714 Some(SealedBlocker::NamespaceCreationDenied),
715 false
716 ),
717 None,
718 "a met floor must ignore a stale blocker rather than manufacture a refusal"
719 );
720 }
721
722 fn sample_policy() -> CapabilityPolicy {
723 CapabilityPolicy {
724 network_allow: vec!["https://api.example.com".to_string()],
725 unix_sockets_allowed: false,
726 filesystem_scope: Some("workdir".to_string()),
727 shell_allow: vec!["python3".to_string()],
728 spawn_allow: vec!["helper".to_string()],
729 env_allow: vec!["TZ".to_string()],
730 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
731 binary: "python3".to_string(),
732 dirs: vec![InterpreterRuntimeDir {
733 path: "/usr/lib/python3.11".to_string(),
734 list_dir: true,
735 }],
736 }],
737 ..CapabilityPolicy::default()
738 }
739 }
740
741 #[test]
742 fn scope_report_mirrors_the_policy_and_the_probed_tier() {
743 let report = scope_report_for_tier(
744 &sample_policy(),
745 ContainmentClass::Scoped,
746 EnforcementTier::KernelFull,
747 None,
748 );
749
750 assert_eq!(report.declared_containment, ContainmentClass::Scoped);
751 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
752 assert!(report.floor_met);
753 assert_eq!(report.shortfall_reason, None);
754 assert_eq!(report.enforcement_tier, "landlock+seccomp");
755 assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
756 assert_eq!(report.network_allow, vec!["https://api.example.com"]);
757 assert!(!report.unix_sockets);
758 assert_eq!(report.shell_allow, vec!["python3"]);
759 assert_eq!(report.spawn_allow, vec!["helper"]);
760 assert_eq!(report.env_allow, vec!["TZ"]);
761 assert!(!report.workdir_exec);
762 assert_eq!(
763 report.interpreter_runtime_grants,
764 vec!["python3: /usr/lib/python3.11 (list_dir)"]
765 );
766 }
767
768 #[test]
772 fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
773 let policy = CapabilityPolicy {
774 shell_allow: vec!["gcc".to_string()],
775 workdir_exec_allowed: true,
776 ..CapabilityPolicy::default()
777 };
778
779 let report = scope_report_for_tier(
780 &policy,
781 ContainmentClass::Scoped,
782 EnforcementTier::KernelFull,
783 None,
784 );
785
786 assert!(report.workdir_exec);
787 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
788 assert!(!report.floor_met);
789 assert_eq!(report.enforcement_tier, "landlock+seccomp");
792 assert!(report
793 .shortfall_reason
794 .as_deref()
795 .expect("refusal")
796 .contains("workdir_exec"));
797
798 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
799 assert_eq!(value["workdir_exec"], true);
800
801 let rendered = report.render();
802 assert!(rendered.contains("workdir exec: true"));
803 assert!(rendered.contains("achieved: advisory"));
804 }
805
806 #[test]
809 fn scope_report_json_always_carries_workdir_exec() {
810 let value: serde_json::Value = serde_json::to_value(scope_report_for_tier(
811 &sample_policy(),
812 ContainmentClass::Scoped,
813 EnforcementTier::KernelFull,
814 None,
815 ))
816 .unwrap();
817 assert_eq!(value["workdir_exec"], false);
818 }
819
820 #[test]
824 fn staged_runtime_grants_are_reported_on_every_tier() {
825 let policy = CapabilityPolicy {
826 shell_allow: vec!["python3".to_string()],
827 shell_staged_runtime: vec![StagedRuntimeGrant {
828 binary: "python3".to_string(),
829 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
830 pin: "conda-4.10.3/python-3.9.19".to_string(),
831 }],
832 ..CapabilityPolicy::default()
833 };
834
835 for tier in ALL_TIERS {
836 let report = scope_report_for_tier(
837 &policy,
838 ContainmentClass::Sealed,
839 *tier,
840 Some(SealedBlocker::NamespaceCreationDenied),
841 );
842 assert_eq!(
843 report.staged_runtime_grants,
844 vec![
845 "python3: /opt/testbed/conda/envs/django__django \
846 (pin: conda-4.10.3/python-3.9.19)"
847 ],
848 "tier {tier:?} must still report the declared staged runtime"
849 );
850 assert!(
851 report.render().contains("staged runtime"),
852 "the rendered report must carry a staged runtime section on tier {tier:?}"
853 );
854 }
855 }
856
857 #[test]
860 fn absent_staged_runtime_reports_nothing() {
861 let report = scope_report_for_tier(
862 &sample_policy(),
863 ContainmentClass::Scoped,
864 EnforcementTier::KernelFull,
865 None,
866 );
867 assert!(report.staged_runtime_grants.is_empty());
868 assert!(report.render().contains("staged runtime: <none>"));
869 }
870
871 #[test]
875 fn grant_set_never_raises_the_achieved_class() {
876 let report = scope_report_for_tier(
877 &sample_policy(),
878 ContainmentClass::Sealed,
879 EnforcementTier::KernelFull,
880 Some(SealedBlocker::NamespaceCreationDenied),
881 );
882 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
883 assert!(!report.floor_met);
884 }
885
886 #[test]
887 fn scope_report_still_reports_when_the_floor_is_unmet() {
888 let report = scope_report_for_tier(
889 &sample_policy(),
890 ContainmentClass::Sealed,
891 EnforcementTier::EnvironmentOnly,
892 Some(SealedBlocker::NotLinux),
893 );
894
895 assert_eq!(report.declared_containment, ContainmentClass::Sealed);
896 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
897 assert!(!report.floor_met);
898 assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
899 assert_eq!(report.enforcement_tier, "none");
900 }
901
902 #[test]
903 fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
904 let report = scope_report_for_tier(
905 &sample_policy(),
906 ContainmentClass::Sealed,
907 EnforcementTier::KernelSealed,
908 None,
909 );
910 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
911 assert!(report.floor_met);
912 assert_eq!(
913 report.enforcement_tier,
914 "mountns+pivot_root+landlock+seccomp"
915 );
916 }
917
918 #[test]
919 fn scope_report_json_uses_wire_names() {
920 let report = scope_report_for_tier(
921 &CapabilityPolicy::default(),
922 ContainmentClass::Advisory,
923 EnforcementTier::KernelSeccompOnly,
924 None,
925 );
926 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
927
928 assert_eq!(value["declared_containment"], "advisory");
929 assert_eq!(value["achieved_containment"], "advisory");
930 assert_eq!(value["floor_met"], true);
931 assert_eq!(value["enforcement_tier"], "seccomp-only");
932 assert!(value.get("shortfall_reason").is_none());
934 }
935
936 #[test]
937 fn rendered_report_names_both_classes_and_the_refusal() {
938 let rendered = scope_report_for_tier(
939 &sample_policy(),
940 ContainmentClass::Sealed,
941 EnforcementTier::KernelFull,
942 Some(SealedBlocker::AppArmorProfileMissing),
943 )
944 .render();
945
946 assert!(rendered.contains("declared: sealed"));
947 assert!(rendered.contains("achieved: scoped"));
948 assert!(rendered.contains("floor met: no"));
949 assert!(rendered.contains("mur-sealed"));
950 assert!(rendered.contains("would refuse to launch"));
951 }
952}