#![allow(clippy::print_stdout, clippy::print_stderr)]
use capsule_runtime::{
detect_containment_decision, detect_sealed_shortfall, detect_userns_grant, HostProbe,
MountAttempt,
};
use murmur_artifact::ContainmentClass;
static COUNTER: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn serial() -> std::sync::MutexGuard<'static, ()> {
COUNTER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[test]
fn two_probes_are_two_readings() {
let _serial = serial();
let before = HostProbe::probes_taken();
let _first = HostProbe::probe();
let _second = HostProbe::probe();
assert_eq!(
HostProbe::probes_taken() - before,
2,
"each `HostProbe::probe` call must read the host, not a value cached for the process",
);
}
#[test]
fn each_standalone_host_question_takes_its_own_reading() {
let _serial = serial();
fn probes_during(ask: impl FnOnce()) -> u64 {
let before = HostProbe::probes_taken();
ask();
HostProbe::probes_taken() - before
}
assert_eq!(
probes_during(|| {
detect_sealed_shortfall();
}),
1,
"detect_sealed_shortfall must read the host once per call",
);
assert_eq!(
probes_during(|| {
detect_userns_grant();
}),
1,
"detect_userns_grant must read the host once per call",
);
assert_eq!(
probes_during(|| {
detect_containment_decision(ContainmentClass::Sealed);
}),
1,
"detect_containment_decision must read the host once per call",
);
}
#[test]
fn only_a_sealed_declaration_is_reported_sealed() {
const SEALED_TIER: &str = "mountns+pivot_root+landlock+seccomp";
let _serial = serial();
let host = detect_containment_decision(ContainmentClass::Sealed).0;
println!(
"host can back: {}, sealed declaration runs on {}",
host.host_containment(),
host.enforcement_tier()
);
for declared in [
ContainmentClass::Advisory,
ContainmentClass::Scoped,
ContainmentClass::Sealed,
] {
let (decision, _) = detect_containment_decision(declared);
assert_eq!(decision.declared(), declared);
assert_eq!(
decision.achieved() == ContainmentClass::Sealed,
declared == ContainmentClass::Sealed
&& host.host_containment() == ContainmentClass::Sealed,
"{declared}: {decision:?}",
);
assert_eq!(
decision.enforcement_tier() == SEALED_TIER,
decision.achieved() == ContainmentClass::Sealed,
"{declared}: {decision:?}",
);
assert!(
decision.achieved() <= decision.host_containment(),
"{decision:?}"
);
assert_eq!(decision.host_containment(), host.host_containment());
}
}
#[test]
fn one_reading_answers_every_namespace_question_from_its_attempts() {
let _serial = serial();
let probe = HostProbe::probe();
let namespaces = probe.namespaces();
println!("namespace reading: {namespaces:?}");
if cfg!(target_os = "linux") {
assert_eq!(
probe.egress_namespace_failure().is_some(),
namespaces.network.is_err(),
"E-CAP-005 must follow the network attempt"
);
}
if namespaces.network.is_err() {
assert_eq!(namespaces.mount, MountAttempt::NotAttempted);
assert_eq!(namespaces.sealed_root, MountAttempt::NotAttempted);
}
if probe.sealed_shortfall().is_none() {
assert!(namespaces.sealed_root_created());
}
}
#[test]
#[ignore = "mutates /proc/sys/user/max_user_namespaces; hand-run under `unshare --user --map-root-user`"]
#[cfg(target_os = "linux")]
fn reprobe_observes_a_change_in_userns_availability() {
use capsule_runtime::{SealedBlocker, SealedShortfall};
const SYSCTL: &str = "/proc/sys/user/max_user_namespaces";
const INVOCATION: &str = "unshare --user --map-root-user cargo test -p capsule-runtime \
--test host_probe -- --ignored \
reprobe_observes_a_change_in_userns_availability";
fn namespace_ok(shortfall: &Option<SealedShortfall>) -> bool {
matches!(
shortfall.as_ref().map(|shortfall| shortfall.blocker),
None | Some(SealedBlocker::LandlockUnavailable)
)
}
let first = HostProbe::probe();
let first_shortfall = first.sealed_shortfall();
println!(
"probe #1: sealed_shortfall {first_shortfall:?}, network {:?}",
first.namespaces().network
);
if !namespace_ok(&first_shortfall) || first.egress_namespace_failure().is_some() {
panic!(
"this host reports {first_shortfall:?} before any change, so it has no \
user-namespace capability to withdraw. Run on a bare Linux host as: {INVOCATION}"
);
}
let original = std::fs::read_to_string(SYSCTL)
.unwrap_or_else(|error| panic!("cannot read {SYSCTL}: {error}. Run as: {INVOCATION}"));
std::fs::write(SYSCTL, "0")
.unwrap_or_else(|error| panic!("cannot write {SYSCTL}: {error}. Run as: {INVOCATION}"));
let second = HostProbe::probe();
let withdrawn = second.sealed_shortfall();
let egress = second.egress_namespace_failure();
println!("probe #2 (max_user_namespaces=0): sealed_shortfall {withdrawn:?}, egress {egress:?}");
let restore = || std::fs::write(SYSCTL, original.trim());
if namespace_ok(&withdrawn) || egress.is_none() || second.namespaces().network.is_ok() {
let _ = restore();
panic!("probe #2 still reports a namespace after {SYSCTL} was zeroed: {withdrawn:?}");
}
assert_eq!(
withdrawn.as_ref().map(|shortfall| shortfall.blocker),
Some(SealedBlocker::KernelUnsupported)
);
assert_eq!(
egress.as_ref().and_then(|failure| failure.errno),
Some(libc::ENOSPC)
);
assert_ne!(
detect_containment_decision(ContainmentClass::Sealed)
.0
.host_containment(),
ContainmentClass::Sealed,
"a host that cannot create a user namespace must not report a sealed ceiling",
);
restore().unwrap_or_else(|error| {
panic!("the kernel refused to restore {SYSCTL} to {original:?}: {error}")
});
let third = HostProbe::probe();
println!(
"probe #3 (restored): sealed_shortfall {:?}, network {:?}",
third.sealed_shortfall(),
third.namespaces().network
);
assert_eq!(
third.sealed_shortfall(),
first_shortfall,
"a condition that has cleared must stop being reported without restarting the process",
);
assert!(third.namespaces().network.is_ok());
assert_eq!(third.egress_namespace_failure(), None);
}
#[test]
#[ignore = "measures host syscall cost; hand-run with --nocapture"]
fn probe_cost() {
const ITERATIONS: u32 = 100;
let start = std::time::Instant::now();
for _ in 0..ITERATIONS {
let _ = HostProbe::probe();
}
let total = start.elapsed();
let mean = total / ITERATIONS;
println!("HostProbe::probe x{ITERATIONS}: total {total:?}, mean {mean:?}");
assert!(
mean < std::time::Duration::from_millis(50),
"mean probe cost {mean:?} is pathological, not merely slow",
);
}