use serde::{Deserialize, Serialize};
use crate::{Caveats, SandboxKind, Scope};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AxisEnforcement {
Kernel,
Interceptor,
Advisory,
}
impl AxisEnforcement {
fn rank(self) -> u8 {
match self {
AxisEnforcement::Advisory => 0,
AxisEnforcement::Interceptor => 1,
AxisEnforcement::Kernel => 2,
}
}
}
impl Ord for AxisEnforcement {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
impl PartialOrd for AxisEnforcement {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct EnforcementReport {
#[serde(skip_serializing_if = "Option::is_none")]
pub fs_read: Option<AxisEnforcement>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fs_write: Option<AxisEnforcement>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exec: Option<AxisEnforcement>,
#[serde(skip_serializing_if = "Option::is_none")]
pub net: Option<AxisEnforcement>,
}
impl EnforcementReport {
#[must_use]
pub fn is_empty(&self) -> bool {
self.fs_read.is_none()
&& self.fs_write.is_none()
&& self.exec.is_none()
&& self.net.is_none()
}
}
fn is_restricted<T: Ord + Clone>(scope: &Scope<T>) -> bool {
matches!(scope, Scope::Only(_))
}
#[must_use]
pub fn enforcement_report(effective: &Caveats, active: SandboxKind) -> EnforcementReport {
let fs = |scope: &Scope<String>| {
is_restricted(scope).then_some(match active {
SandboxKind::Landlock
| SandboxKind::Seatbelt
| SandboxKind::MinimalRootfs
| SandboxKind::MicroVm => AxisEnforcement::Kernel,
SandboxKind::AppContainer => AxisEnforcement::Kernel,
SandboxKind::None => AxisEnforcement::Interceptor,
})
};
EnforcementReport {
fs_read: fs(&effective.fs_read),
fs_write: fs(&effective.fs_write),
exec: is_restricted(&effective.exec).then_some(match active {
SandboxKind::AppContainer if crate::sandbox::exec_fully_denied(effective) => {
AxisEnforcement::Kernel
}
SandboxKind::Seatbelt | SandboxKind::MinimalRootfs | SandboxKind::MicroVm => {
AxisEnforcement::Kernel
}
SandboxKind::Landlock | SandboxKind::AppContainer | SandboxKind::None => {
AxisEnforcement::Interceptor
}
}),
net: is_restricted(&effective.net).then_some(match active {
SandboxKind::AppContainer
if crate::sandbox::net_fully_denied(effective)
|| crate::sandbox::net_loopback_only(effective) =>
{
AxisEnforcement::Kernel
}
SandboxKind::AppContainer => AxisEnforcement::Advisory,
SandboxKind::MicroVm => AxisEnforcement::Kernel,
SandboxKind::Seatbelt
if crate::sandbox::net_fully_denied(effective)
|| crate::sandbox::net_loopback_only(effective) =>
{
AxisEnforcement::Kernel
}
SandboxKind::Landlock
if crate::sandbox::net_fully_denied(effective)
&& crate::sandbox::landlock_net_capable() =>
{
AxisEnforcement::Kernel
}
SandboxKind::Landlock
| SandboxKind::Seatbelt
| SandboxKind::MinimalRootfs
| SandboxKind::None => AxisEnforcement::Advisory,
}),
}
}
#[must_use]
pub fn fence_strength(report: &EnforcementReport) -> Option<AxisEnforcement> {
[report.fs_read, report.fs_write, report.exec, report.net]
.into_iter()
.flatten()
.min()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CountBound, Scope};
fn fully_restricted() -> Caveats {
Caveats {
fs_read: Scope::only(["/r".to_string()]),
fs_write: Scope::only(["/w".to_string()]),
exec: Scope::only(["echo".to_string()]),
net: Scope::only(["example.com".to_string()]),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
}
}
#[test]
fn landlock_marks_fs_kernel_exec_interceptor_net_advisory() {
let r = enforcement_report(&fully_restricted(), SandboxKind::Landlock);
assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
assert_eq!(r.net, Some(AxisEnforcement::Advisory));
}
#[test]
fn landlock_marks_net_kernel_when_net_fully_denied_and_v4_capable() {
let net_denied = crate::Caveats {
net: crate::Scope::none(),
..crate::Caveats::top()
};
let r = enforcement_report(&net_denied, SandboxKind::Landlock);
let expected = if crate::sandbox::landlock_net_capable() {
Some(AxisEnforcement::Kernel)
} else {
Some(AxisEnforcement::Advisory)
};
assert_eq!(
r.net, expected,
"Landlock net enforcement depends on V4 kernel support"
);
assert_eq!(r.fs_read, None);
assert_eq!(r.fs_write, None);
assert_eq!(r.exec, None);
}
#[test]
fn seatbelt_marks_fs_and_exec_kernel_net_advisory() {
let r = enforcement_report(&fully_restricted(), SandboxKind::Seatbelt);
assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
assert_eq!(r.net, Some(AxisEnforcement::Advisory));
}
#[test]
fn exec_is_kernel_under_seatbelt_interceptor_elsewhere() {
let cav = Caveats {
exec: Scope::only(["git".to_string()]),
..Caveats::top()
};
assert_eq!(
enforcement_report(&cav, SandboxKind::Seatbelt).exec,
Some(AxisEnforcement::Kernel)
);
assert_eq!(
enforcement_report(&cav, SandboxKind::Landlock).exec,
Some(AxisEnforcement::Interceptor)
);
assert_eq!(
enforcement_report(&cav, SandboxKind::None).exec,
Some(AxisEnforcement::Interceptor)
);
}
#[test]
fn minimal_rootfs_marks_fs_and_exec_kernel_net_advisory() {
let r = enforcement_report(&fully_restricted(), SandboxKind::MinimalRootfs);
assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
assert_eq!(r.net, Some(AxisEnforcement::Advisory));
}
#[test]
fn exec_is_kernel_under_minimal_rootfs_interceptor_under_landlock() {
let cav = Caveats {
exec: Scope::only(["cat".to_string()]),
..Caveats::top()
};
assert_eq!(
enforcement_report(&cav, SandboxKind::MinimalRootfs).exec,
Some(AxisEnforcement::Kernel),
"minimal-rootfs closes exec by identity ⇒ kernel"
);
assert_eq!(
enforcement_report(&cav, SandboxKind::Landlock).exec,
Some(AxisEnforcement::Interceptor),
"a Landlock-only boundary run stays exec→interceptor (ADR 0011)"
);
}
#[test]
fn micro_vm_marks_all_axes_kernel() {
let r = enforcement_report(&fully_restricted(), SandboxKind::MicroVm);
assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
assert_eq!(r.net, Some(AxisEnforcement::Kernel));
assert_eq!(fence_strength(&r), Some(AxisEnforcement::Kernel));
}
#[test]
fn appcontainer_marks_fs_kernel_exec_interceptor_net_advisory_for_allowlist() {
let r = enforcement_report(&fully_restricted(), SandboxKind::AppContainer);
assert_eq!(r.fs_read, Some(AxisEnforcement::Kernel));
assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
assert_eq!(r.net, Some(AxisEnforcement::Advisory));
}
#[test]
fn appcontainer_marks_net_kernel_for_deny_all() {
let net_deny_all = Caveats {
net: Scope::none(),
..Caveats::top()
};
let r = enforcement_report(&net_deny_all, SandboxKind::AppContainer);
assert_eq!(r.net, Some(AxisEnforcement::Kernel));
assert_eq!(r.fs_read, None);
assert_eq!(r.fs_write, None);
assert_eq!(r.exec, None);
}
#[test]
fn appcontainer_marks_net_kernel_for_loopback_only() {
for host in ["localhost", "127.0.0.1", "::1"] {
let loopback_only = Caveats {
net: Scope::only([host.to_string()]),
..Caveats::top()
};
let r = enforcement_report(&loopback_only, SandboxKind::AppContainer);
assert_eq!(
r.net,
Some(AxisEnforcement::Kernel),
"loopback host {host} must be Kernel under AppContainer"
);
}
}
#[test]
fn appcontainer_marks_exec_kernel_for_deny_all() {
let exec_deny_all = Caveats {
exec: Scope::none(),
..Caveats::top()
};
let r = enforcement_report(&exec_deny_all, SandboxKind::AppContainer);
assert_eq!(r.exec, Some(AxisEnforcement::Kernel));
assert_eq!(r.fs_read, None);
assert_eq!(r.fs_write, None);
assert_eq!(r.net, None);
}
#[test]
fn appcontainer_exec_allowlist_stays_interceptor() {
let exec_allowlist = Caveats {
exec: Scope::only(["echo".to_string()]),
..Caveats::top()
};
let r = enforcement_report(&exec_allowlist, SandboxKind::AppContainer);
assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
}
#[test]
fn seatbelt_net_kernel_for_empty_and_loopback_advisory_for_remote_host() {
let net_report = |net| {
enforcement_report(
&Caveats {
net,
..Caveats::top()
},
SandboxKind::Seatbelt,
)
.net
};
assert_eq!(net_report(Scope::none()), Some(AxisEnforcement::Kernel));
for host in ["localhost", "127.0.0.1", "::1"] {
assert_eq!(
net_report(Scope::only([host.to_string()])),
Some(AxisEnforcement::Kernel),
"loopback host {host} must report kernel"
);
}
assert_eq!(
net_report(Scope::only(["example.com".to_string()])),
Some(AxisEnforcement::Advisory)
);
assert_eq!(
net_report(Scope::only([
"localhost".to_string(),
"example.com".to_string()
])),
Some(AxisEnforcement::Advisory)
);
}
#[test]
fn noop_host_never_reports_kernel() {
let r = enforcement_report(&fully_restricted(), SandboxKind::None);
assert_eq!(r.fs_read, Some(AxisEnforcement::Interceptor));
assert_eq!(r.fs_write, Some(AxisEnforcement::Interceptor));
assert_eq!(r.exec, Some(AxisEnforcement::Interceptor));
assert_eq!(r.net, Some(AxisEnforcement::Advisory));
for axis in [r.fs_read, r.fs_write, r.exec, r.net] {
assert_ne!(
axis,
Some(AxisEnforcement::Kernel),
"Noop must never claim kernel"
);
}
}
#[test]
fn unrestricted_axes_are_omitted() {
let top = Caveats::top(); let r = enforcement_report(&top, SandboxKind::Landlock);
assert!(
r.is_empty(),
"all-`All` caveats produce an empty report: {r:?}"
);
assert_eq!(r.fs_write, None);
}
#[test]
fn only_restricted_axes_appear() {
let caveats = Caveats {
fs_write: Scope::only(["/w".to_string()]),
..Caveats::top()
};
let r = enforcement_report(&caveats, SandboxKind::Landlock);
assert_eq!(r.fs_write, Some(AxisEnforcement::Kernel));
assert_eq!(r.fs_read, None);
assert_eq!(r.exec, None);
assert_eq!(r.net, None);
}
#[test]
fn axis_enforcement_serializes_snake_case() {
assert_eq!(
serde_json::to_value(AxisEnforcement::Kernel).unwrap(),
serde_json::json!("kernel")
);
assert_eq!(
serde_json::to_value(AxisEnforcement::Interceptor).unwrap(),
serde_json::json!("interceptor")
);
assert_eq!(
serde_json::to_value(AxisEnforcement::Advisory).unwrap(),
serde_json::json!("advisory")
);
}
#[test]
fn axis_enforcement_orders_ascending_advisory_to_kernel() {
use AxisEnforcement::{Advisory, Interceptor, Kernel};
assert!(Advisory < Interceptor);
assert!(Interceptor < Kernel);
assert!(
Advisory < Kernel,
"the fail-open footgun: Advisory must be < Kernel"
);
assert_eq!(
[Interceptor, Kernel, Advisory].into_iter().max(),
Some(Kernel)
);
assert_eq!(
[Interceptor, Kernel, Advisory].into_iter().min(),
Some(Advisory)
);
}
#[test]
fn fence_strength_is_the_weakest_restricted_axis() {
let r = enforcement_report(&fully_restricted(), SandboxKind::Landlock);
assert_eq!(fence_strength(&r), Some(AxisEnforcement::Advisory));
}
#[test]
fn fence_strength_all_kernel_when_only_fs_restricted() {
let caveats = Caveats {
fs_read: Scope::only(["/r".to_string()]),
fs_write: Scope::only(["/w".to_string()]),
..Caveats::top()
};
let r = enforcement_report(&caveats, SandboxKind::Landlock);
assert_eq!(fence_strength(&r), Some(AxisEnforcement::Kernel));
}
#[test]
fn fence_strength_empty_report_is_none() {
let r = enforcement_report(&Caveats::top(), SandboxKind::Landlock);
assert!(r.is_empty());
assert_eq!(fence_strength(&r), None);
}
#[test]
fn fence_strength_single_axis_no_backend() {
let caveats = Caveats {
fs_write: Scope::only(["/w".to_string()]),
..Caveats::top()
};
let r = enforcement_report(&caveats, SandboxKind::None);
assert_eq!(fence_strength(&r), Some(AxisEnforcement::Interceptor));
}
#[test]
fn exec_kernel_is_identity_not_interpreter_behavior() {
let interp = Caveats {
exec: Scope::only(["sh".to_string()]),
..Caveats::top()
};
let r = enforcement_report(&interp, SandboxKind::Seatbelt);
assert_eq!(
r.exec,
Some(AxisEnforcement::Kernel),
"a granted interpreter still earns exec→kernel (identity, not behavior)"
);
assert_eq!(r.fs_read, None);
assert_eq!(r.fs_write, None);
assert_eq!(r.net, None);
}
}