use assay_policy::tiers::{compile_landlock_net, LandlockRejectReason, NetworkPolicy};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetReject {
pub reason: NetRejectReason,
pub entry: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetRejectReason {
Protocol,
Range,
Cidr,
NegativeDeny,
Destination,
PortZero,
Malformed,
}
impl NetRejectReason {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
NetRejectReason::Protocol => "protocol_not_tcp",
NetRejectReason::Range => "port_range_not_allowed",
NetRejectReason::Cidr => "cidr_not_expressible",
NetRejectReason::NegativeDeny => "negative_deny_not_expressible",
NetRejectReason::Destination => "destination_not_expressible",
NetRejectReason::PortZero => "port_zero_not_allowed",
NetRejectReason::Malformed => "malformed_net_entry",
}
}
}
fn map_compile_reason(reason: LandlockRejectReason) -> NetRejectReason {
match reason {
LandlockRejectReason::Cidr => NetRejectReason::Cidr,
LandlockRejectReason::NegativeDeny => NetRejectReason::NegativeDeny,
LandlockRejectReason::Destination => NetRejectReason::Destination,
LandlockRejectReason::PortZero => NetRejectReason::PortZero,
}
}
fn looks_like_cidr_or_ip(s: &str) -> bool {
if s.contains('/') {
return true;
}
s.parse::<std::net::IpAddr>().is_ok()
}
fn classify_allow(entry: &str, network: &mut NetworkPolicy) -> Option<NetReject> {
let token = entry.trim();
if let Ok(port) = token.parse::<u16>() {
network.allow_ports.push(port);
return None;
}
if let Some(rest) = token
.strip_prefix("tcp/")
.or_else(|| token.strip_prefix("tcp:"))
{
return match rest.parse::<u16>() {
Ok(port) => {
network.allow_ports.push(port);
None
}
Err(_) => Some(NetReject {
reason: NetRejectReason::Malformed,
entry: entry.to_string(),
}),
};
}
for sep in ['/', ':'] {
if let Some((proto, _)) = token.split_once(sep) {
if !proto.is_empty() && proto.chars().all(|c| c.is_ascii_alphabetic()) {
return Some(NetReject {
reason: NetRejectReason::Protocol,
entry: entry.to_string(),
});
}
}
}
if token
.split_once('-')
.is_some_and(|(a, b)| a.parse::<u16>().is_ok() && b.parse::<u16>().is_ok())
{
return Some(NetReject {
reason: NetRejectReason::Range,
entry: entry.to_string(),
});
}
if looks_like_cidr_or_ip(token) {
network.allow_cidrs.push(token.to_string());
return None;
}
if token.contains(':') || token.chars().any(|c| c.is_ascii_alphabetic()) {
return Some(NetReject {
reason: NetRejectReason::Destination,
entry: entry.to_string(),
});
}
Some(NetReject {
reason: NetRejectReason::Malformed,
entry: entry.to_string(),
})
}
pub fn plan_landlock_net_ports(net: &crate::policy::NetPolicy) -> Result<Vec<u16>, Vec<NetReject>> {
let mut network = NetworkPolicy::default();
let mut rejects: Vec<NetReject> = Vec::new();
for entry in &net.allow {
if let Some(reject) = classify_allow(entry, &mut network) {
rejects.push(reject);
}
}
for entry in &net.deny {
let token = entry.trim();
if token.parse::<u16>().is_ok() {
network.deny_ports.push(token.parse().unwrap());
} else if looks_like_cidr_or_ip(token) {
network.deny_cidrs.push(token.to_string());
} else {
network.deny_destinations.push(token.to_string());
}
}
match compile_landlock_net(&network) {
Ok(target) => {
if rejects.is_empty() {
Ok(target.allowed_connect_tcp_ports)
} else {
Err(rejects)
}
}
Err(compile_rejects) => {
for r in compile_rejects {
rejects.push(NetReject {
reason: map_compile_reason(r.reason),
entry: r.detail,
});
}
Err(rejects)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::NetPolicy;
fn reasons(err: &[NetReject]) -> Vec<NetRejectReason> {
let mut r: Vec<NetRejectReason> = err.iter().map(|e| e.reason).collect();
r.sort_by_key(|x| x.as_str());
r.dedup();
r
}
fn allow(entries: &[&str]) -> NetPolicy {
NetPolicy {
allow: entries.iter().map(|s| s.to_string()).collect(),
deny: vec![],
}
}
#[test]
fn accepts_bare_and_tcp_prefixed_ports_sorted_deduped() {
let ports = plan_landlock_net_ports(&allow(&["443", "tcp/80", "443"])).unwrap();
assert_eq!(ports, vec![80, 443]);
}
#[test]
fn accepts_empty_as_deny_all() {
assert_eq!(
plan_landlock_net_ports(&allow(&[])).unwrap(),
Vec::<u16>::new()
);
}
#[test]
fn rejects_udp_and_quic_as_protocol() {
for e in ["udp/53", "quic/443"] {
let err = plan_landlock_net_ports(&allow(&[e])).unwrap_err();
assert_eq!(reasons(&err), vec![NetRejectReason::Protocol], "{e}");
}
}
#[test]
fn rejects_port_range() {
let err = plan_landlock_net_ports(&allow(&["443-445"])).unwrap_err();
assert_eq!(reasons(&err), vec![NetRejectReason::Range]);
}
#[test]
fn rejects_ip_and_cidr() {
for e in ["10.0.0.0/8", "203.0.113.10", "::1"] {
let err = plan_landlock_net_ports(&allow(&[e])).unwrap_err();
assert_eq!(reasons(&err), vec![NetRejectReason::Cidr], "{e}");
}
}
#[test]
fn rejects_host_and_wildcard_destinations() {
for e in ["example.com:443", "*.internal"] {
let err = plan_landlock_net_ports(&allow(&[e])).unwrap_err();
assert_eq!(reasons(&err), vec![NetRejectReason::Destination], "{e}");
}
}
#[test]
fn rejects_port_zero() {
let err = plan_landlock_net_ports(&allow(&["0"])).unwrap_err();
assert_eq!(reasons(&err), vec![NetRejectReason::PortZero]);
}
#[test]
fn rejects_any_deny_as_negative() {
let net = NetPolicy {
allow: vec!["443".to_string()],
deny: vec!["4444".to_string()],
};
let err = plan_landlock_net_ports(&net).unwrap_err();
assert_eq!(reasons(&err), vec![NetRejectReason::NegativeDeny]);
}
#[test]
fn reason_ids_are_stable() {
assert_eq!(NetRejectReason::Protocol.as_str(), "protocol_not_tcp");
assert_eq!(NetRejectReason::Range.as_str(), "port_range_not_allowed");
assert_eq!(NetRejectReason::PortZero.as_str(), "port_zero_not_allowed");
}
}