use serde::{Deserialize, Serialize};
pub const SCHEMA_V1: &str = "assay.enforcement_health.v1";
pub const SCOPE_TCP_CONNECT_LANDLOCK_PORT: &str = "tcp_connect_landlock_port";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Active,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mechanism {
Landlock,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PolicySemantics {
Allowlist,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReasonCode {
LandlockAbiTooOld,
LandlockDisabled,
NoNewPrivsFailed,
RestrictSelfFailed,
RulesetBuildFailed,
PolicyNotExpressible,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LandlockBlock {
pub abi: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub net_connect_tcp_supported: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub handled_access_net: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_connect_tcp_ports: Option<Vec<u16>>,
pub no_new_privs_confirmed: bool,
pub restrict_self_confirmed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Probe {
pub kind: String,
pub transport: String,
pub blocked_action: String,
pub blocked_port: u16,
pub blocked_errno: String,
pub listener_reached: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Failure {
pub reason_code: ReasonCode,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnforcementHealthV1 {
pub schema: String,
pub status: Status,
pub mechanism: Mechanism,
pub scope: String,
pub policy_semantics: PolicySemantics,
#[serde(skip_serializing_if = "Option::is_none")]
pub failure: Option<Failure>,
pub landlock: LandlockBlock,
pub probe: Option<Probe>,
pub non_claims: Vec<String>,
}
fn landlock_non_claims() -> Vec<String> {
[
"no ip or cidr enforcement",
"no hostname enforcement",
"no destination identity enforcement",
"no udp or quic enforcement",
"no http or tls route policy",
"not a replacement for cgroup/connect4 endpoint enforcement",
]
.iter()
.map(|s| s.to_string())
.collect()
}
impl EnforcementHealthV1 {
#[must_use]
pub fn landlock_active(abi: u32, allowed_ports: Vec<u16>, probe: Option<Probe>) -> Self {
Self {
schema: SCHEMA_V1.to_string(),
status: Status::Active,
mechanism: Mechanism::Landlock,
scope: SCOPE_TCP_CONNECT_LANDLOCK_PORT.to_string(),
policy_semantics: PolicySemantics::Allowlist,
failure: None,
landlock: LandlockBlock {
abi,
net_connect_tcp_supported: None,
handled_access_net: Some(vec!["connect_tcp".to_string()]),
allowed_connect_tcp_ports: Some(allowed_ports),
no_new_privs_confirmed: true,
restrict_self_confirmed: true,
},
probe,
non_claims: landlock_non_claims(),
}
}
#[must_use]
pub fn landlock_failed(
abi: u32,
reason_code: ReasonCode,
detail: impl Into<String>,
net_connect_tcp_supported: bool,
no_new_privs_confirmed: bool,
) -> Self {
Self {
schema: SCHEMA_V1.to_string(),
status: Status::Failed,
mechanism: Mechanism::Landlock,
scope: SCOPE_TCP_CONNECT_LANDLOCK_PORT.to_string(),
policy_semantics: PolicySemantics::Allowlist,
failure: Some(Failure {
reason_code,
detail: detail.into(),
}),
landlock: LandlockBlock {
abi,
net_connect_tcp_supported: Some(net_connect_tcp_supported),
handled_access_net: None,
allowed_connect_tcp_ports: None,
no_new_privs_confirmed,
restrict_self_confirmed: false,
},
probe: None,
non_claims: landlock_non_claims(),
}
}
pub fn write_to(&self, path: &std::path::Path) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, json)
}
}
#[cfg(test)]
mod fixture_values {
use super::*;
fn base_non_claims() -> Vec<String> {
[
"no ip or cidr enforcement",
"no hostname enforcement",
"no destination identity enforcement",
"no udp or quic enforcement",
"no http or tls route policy",
"not a replacement for cgroup/connect4 endpoint enforcement",
]
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn active_with_probe() -> EnforcementHealthV1 {
EnforcementHealthV1 {
schema: SCHEMA_V1.to_string(),
status: Status::Active,
mechanism: Mechanism::Landlock,
scope: SCOPE_TCP_CONNECT_LANDLOCK_PORT.to_string(),
policy_semantics: PolicySemantics::Allowlist,
failure: None,
landlock: LandlockBlock {
abi: 4,
net_connect_tcp_supported: None,
handled_access_net: Some(vec!["connect_tcp".to_string()]),
allowed_connect_tcp_ports: Some(vec![443]),
no_new_privs_confirmed: true,
restrict_self_confirmed: true,
},
probe: Some(Probe {
kind: "real_block".to_string(),
transport: "ipv4".to_string(),
blocked_action: "tcp_connect".to_string(),
blocked_port: 4444,
blocked_errno: "EACCES".to_string(),
listener_reached: false,
}),
non_claims: base_non_claims(),
}
}
pub fn active_no_probe() -> EnforcementHealthV1 {
EnforcementHealthV1 {
probe: None,
..active_with_probe()
}
}
pub fn failed() -> EnforcementHealthV1 {
EnforcementHealthV1 {
schema: SCHEMA_V1.to_string(),
status: Status::Failed,
mechanism: Mechanism::Landlock,
scope: SCOPE_TCP_CONNECT_LANDLOCK_PORT.to_string(),
policy_semantics: PolicySemantics::Allowlist,
failure: Some(Failure {
reason_code: ReasonCode::LandlockAbiTooOld,
detail: "Landlock ABI 4 is required for TCP connect port allowlists".to_string(),
}),
landlock: LandlockBlock {
abi: 3,
net_connect_tcp_supported: Some(false),
handled_access_net: None,
allowed_connect_tcp_ports: None,
no_new_privs_confirmed: false,
restrict_self_confirmed: false,
},
probe: None,
non_claims: base_non_claims(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
const FIXTURE_DIR: &str = "tests/fixtures/enforcement_health/v1";
fn fixture_path(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(FIXTURE_DIR)
.join(name)
}
#[test]
#[ignore = "generator; run with --ignored to regenerate fixtures"]
fn emit_fixtures() {
for (name, value) in [
(
"active_with_probe.json",
fixture_values::active_with_probe(),
),
("active_no_probe.json", fixture_values::active_no_probe()),
("failed.json", fixture_values::failed()),
] {
let mut json = serde_json::to_string_pretty(&value).unwrap();
json.push('\n');
std::fs::write(fixture_path(name), json).unwrap();
}
}
fn load_fixture(name: &str) -> (String, EnforcementHealthV1) {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join(FIXTURE_DIR)
.join(name);
let bytes = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()));
let value: EnforcementHealthV1 = serde_json::from_str(&bytes)
.unwrap_or_else(|e| panic!("parse fixture {}: {e}", path.display()));
(bytes, value)
}
fn assert_round_trip(name: &str) -> EnforcementHealthV1 {
let (bytes, value) = load_fixture(name);
let reserialized = serde_json::to_string_pretty(&value).unwrap();
assert_eq!(
reserialized.trim_end(),
bytes.trim_end(),
"fixture {name} is not byte-stable through the typed carrier"
);
value
}
#[test]
fn active_with_probe_round_trips_and_claims_real_block() {
let v = assert_round_trip("active_with_probe.json");
assert_eq!(v.schema, SCHEMA_V1);
assert_eq!(v.status, Status::Active);
assert!(v.failure.is_none());
let probe = v.probe.expect("active_with_probe carries a probe");
assert_eq!(probe.blocked_errno, "EACCES");
assert!(
!probe.listener_reached,
"a real block must not reach the listener"
);
assert!(v.landlock.no_new_privs_confirmed);
assert!(v.landlock.restrict_self_confirmed);
}
#[test]
fn active_no_probe_round_trips_with_null_probe() {
let v = assert_round_trip("active_no_probe.json");
assert_eq!(v.status, Status::Active);
assert!(
v.probe.is_none(),
"active without a probe carries probe: null (ruleset applied, no real-block claim)"
);
assert!(v.landlock.restrict_self_confirmed);
}
#[test]
fn failed_round_trips_with_machine_readable_reason() {
let v = assert_round_trip("failed.json");
assert_eq!(v.status, Status::Failed);
let failure = v.failure.expect("failed carries a failure block");
assert_eq!(failure.reason_code, ReasonCode::LandlockAbiTooOld);
assert!(!v.landlock.restrict_self_confirmed);
assert_eq!(v.landlock.net_connect_tcp_supported, Some(false));
}
#[test]
fn v1_status_has_no_not_applicable_or_absent() {
assert!(serde_json::from_str::<Status>("\"active\"").is_ok());
assert!(serde_json::from_str::<Status>("\"failed\"").is_ok());
assert!(serde_json::from_str::<Status>("\"absent\"").is_err());
assert!(serde_json::from_str::<Status>("\"not_applicable\"").is_err());
}
#[test]
fn schema_id_is_an_explicit_version_bump_from_v0() {
assert_eq!(SCHEMA_V1, "assay.enforcement_health.v1");
assert_ne!(
SCHEMA_V1, "assay.enforcement_health.v0",
"v1 must be a distinct schema id from v0 (explicit version bump, not a silent reshape)"
);
}
}