#![allow(missing_docs)]
use std::net::IpAddr;
use std::str::FromStr;
use ipnetwork::IpNetwork;
use microsandbox_network::policy::{
Action, Destination, DestinationGroup, Direction, DomainName, DomainNameError, PortRange,
Protocol, Rule,
};
#[derive(Debug, thiserror::Error)]
pub enum RuleParseError {
#[error(
"rule token `{token}` is missing `@`; expected `<action>[:<direction>]@<target>[:<proto>[:<ports>]]`"
)]
MissingAt { token: String },
#[error("`{raw}` is not a recognized action. Expected `allow` or `deny`{suggestion}")]
InvalidAction {
raw: String,
suggestion: SuggestionDisplay,
},
#[error(
"`{raw}` is not a recognized direction. Expected `egress`, `ingress`, or `any`{suggestion}"
)]
InvalidDirection {
raw: String,
suggestion: SuggestionDisplay,
},
#[error(
"`{raw}` is not a valid target. Expected: any, a group name (public, private, loopback, link-local, meta, multicast, host), an IP, a CIDR, a domain (with dot), domain=<name>, or suffix=<domain>{suggestion}"
)]
InvalidTarget {
raw: String,
suggestion: SuggestionDisplay,
},
#[error(
"`{raw}` is ambiguous (looks like a single-label hostname or a typoed keyword). Use `domain={raw}` to target a literal hostname{suggestion}"
)]
AmbiguousBareToken {
raw: String,
suggestion: SuggestionDisplay,
},
#[error("invalid domain `{raw}`: {source}")]
InvalidDomain {
raw: String,
#[source]
source: DomainNameError,
},
#[error("invalid CIDR `{raw}`")]
InvalidCidr { raw: String },
#[error("invalid IP address `{raw}`")]
InvalidIp { raw: String },
#[error(
"`{raw}` is not a recognized protocol. Expected `any`, `tcp`, `udp`, `icmpv4`, or `icmpv6`{suggestion}"
)]
InvalidProtocol {
raw: String,
suggestion: SuggestionDisplay,
},
#[error("`{raw}` is not a valid ports value. Expected `any`, `<port>`, or `<lo>-<hi>`")]
InvalidPorts { raw: String },
#[error("invalid port range {lo}..{hi}; lo must be <= hi")]
InvalidPortRange { lo: u16, hi: u16 },
#[error(
"ingress and any-direction rules do not support ICMP; only TCP (and UDP when UDP publishing lands)"
)]
IngressDoesNotSupportIcmp,
#[error(
"rule token `{token}` has trailing fields after `<ports>`; if this is an IPv6 address, wrap it as `[<addr>]`"
)]
TrailingJunk { token: String },
#[error("unclosed `[` in rule token `{token}`; IPv6 addresses must be wrapped as `[<addr>]`")]
UnclosedBracket { token: String },
#[error(
"rule token `{token}` has unexpected content after `]`; expected `:<proto>` or end of token"
)]
UnexpectedAfterBracket { token: String },
#[error("`{raw}` is not an IPv6 address or CIDR; only IPv6 forms belong inside `[...]`")]
BracketedNotIpv6 { raw: String },
#[error(
"`{value}` is a port, not a protocol; did you mean `{target}:tcp:{value}`? port numbers don't belong in the target"
)]
PortInProtocolSlot { target: String, value: String },
}
#[derive(Debug)]
pub struct SuggestionDisplay(Option<&'static str>);
impl std::fmt::Display for SuggestionDisplay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
Some(s) => write!(f, ". Did you mean `{s}`?"),
None => Ok(()),
}
}
}
pub fn parse_rule_token(token: &str) -> Result<Rule, RuleParseError> {
let (left, right) = token
.split_once('@')
.ok_or_else(|| RuleParseError::MissingAt {
token: token.to_string(),
})?;
let (action, direction) = parse_action_and_direction(left)?;
let (destination, proto_raw, ports_raw) = match right.strip_prefix('[') {
Some(after_open) => parse_bracketed_right(after_open, token)?,
None => parse_unbracketed_right(right, token)?,
};
let protocols = match proto_raw {
None => Vec::new(),
Some(p) => parse_protocol(p)?,
};
let ports = match ports_raw {
None => Vec::new(),
Some(p) => parse_ports(p)?,
};
if matches!(direction, Direction::Ingress | Direction::Any)
&& protocols
.iter()
.any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
{
return Err(RuleParseError::IngressDoesNotSupportIcmp);
}
Ok(Rule {
direction,
destination,
protocols,
ports,
action,
})
}
fn parse_bracketed_right<'a>(
after_open: &'a str,
token: &str,
) -> Result<(Destination, Option<&'a str>, Option<&'a str>), RuleParseError> {
let close = after_open
.find(']')
.ok_or_else(|| RuleParseError::UnclosedBracket {
token: token.to_string(),
})?;
let inner = &after_open[..close];
let after_close = &after_open[close + 1..];
let destination = parse_bracketed_target(inner)?;
if after_close.is_empty() {
return Ok((destination, None, None));
}
let after_colon =
after_close
.strip_prefix(':')
.ok_or_else(|| RuleParseError::UnexpectedAfterBracket {
token: token.to_string(),
})?;
let mut parts = after_colon.splitn(3, ':');
let proto = parts.next();
let ports = parts.next();
if parts.next().is_some() {
return Err(RuleParseError::TrailingJunk {
token: token.to_string(),
});
}
Ok((destination, proto, ports))
}
fn parse_unbracketed_right<'a>(
right: &'a str,
token: &str,
) -> Result<(Destination, Option<&'a str>, Option<&'a str>), RuleParseError> {
let mut parts = right.splitn(4, ':');
let target_raw = parts.next().unwrap_or("");
let proto_raw = parts.next();
let ports_raw = parts.next();
if parts.next().is_some() {
return Err(RuleParseError::TrailingJunk {
token: token.to_string(),
});
}
if let Some(p) = proto_raw
&& looks_like_port(p)
{
return Err(RuleParseError::PortInProtocolSlot {
target: target_raw.to_string(),
value: p.to_string(),
});
}
let destination = parse_target(target_raw)?;
Ok((destination, proto_raw, ports_raw))
}
pub fn parse_rule_list(comma_separated: &str) -> Result<Vec<Rule>, RuleParseError> {
comma_separated
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(parse_rule_token)
.collect()
}
const ACTION_KEYWORDS: &[&str] = &["allow", "deny"];
const DIRECTION_KEYWORDS: &[&str] = &["egress", "ingress", "any"];
const GROUP_KEYWORDS: &[&str] = &[
"public",
"private",
"loopback",
"link-local",
"meta",
"multicast",
"host",
];
const PROTOCOL_KEYWORDS: &[&str] = &["any", "tcp", "udp", "icmpv4", "icmpv6"];
fn parse_action_and_direction(left: &str) -> Result<(Action, Direction), RuleParseError> {
let (action_raw, direction_raw) = match left.split_once(':') {
Some((a, d)) => (a, Some(d)),
None => (left, None),
};
let action = match action_raw {
"allow" => Action::Allow,
"deny" => Action::Deny,
other => {
return Err(RuleParseError::InvalidAction {
raw: other.to_string(),
suggestion: SuggestionDisplay(suggest(other, ACTION_KEYWORDS)),
});
}
};
let direction = match direction_raw {
None => Direction::Egress,
Some("egress") => Direction::Egress,
Some("ingress") => Direction::Ingress,
Some("any") => Direction::Any,
Some(other) => {
return Err(RuleParseError::InvalidDirection {
raw: other.to_string(),
suggestion: SuggestionDisplay(suggest(other, DIRECTION_KEYWORDS)),
});
}
};
Ok((action, direction))
}
fn parse_target(raw: &str) -> Result<Destination, RuleParseError> {
if raw.is_empty() {
return Err(RuleParseError::InvalidTarget {
raw: raw.to_string(),
suggestion: SuggestionDisplay(None),
});
}
if raw == "any" {
return Ok(Destination::Any);
}
if let Some(group) = group_from_keyword(raw) {
return Ok(Destination::Group(group));
}
if let Some(rest) = raw.strip_prefix("suffix=") {
let name = DomainName::from_str(rest).map_err(|source| RuleParseError::InvalidDomain {
raw: rest.to_string(),
source,
})?;
return Ok(Destination::DomainSuffix(name));
}
if let Some(rest) = raw.strip_prefix("domain=") {
let name = DomainName::from_str(rest).map_err(|source| RuleParseError::InvalidDomain {
raw: rest.to_string(),
source,
})?;
return Ok(Destination::Domain(name));
}
if raw.contains('/') {
let net = IpNetwork::from_str(raw).map_err(|_| RuleParseError::InvalidCidr {
raw: raw.to_string(),
})?;
return Ok(Destination::Cidr(net));
}
if let Ok(ip) = IpAddr::from_str(raw) {
let prefix = if ip.is_ipv4() { 32 } else { 128 };
let net = IpNetwork::new(ip, prefix).map_err(|_| RuleParseError::InvalidIp {
raw: raw.to_string(),
})?;
return Ok(Destination::Cidr(net));
}
if raw.contains('.') {
return DomainName::from_str(raw)
.map(Destination::Domain)
.map_err(|source| RuleParseError::InvalidDomain {
raw: raw.to_string(),
source,
});
}
let suggestion = suggest(raw, GROUP_KEYWORDS);
Err(RuleParseError::AmbiguousBareToken {
raw: raw.to_string(),
suggestion: SuggestionDisplay(suggestion),
})
}
fn parse_bracketed_target(inner: &str) -> Result<Destination, RuleParseError> {
if inner.contains('/') {
let net = IpNetwork::from_str(inner).map_err(|_| RuleParseError::InvalidCidr {
raw: inner.to_string(),
})?;
if !matches!(net, IpNetwork::V6(_)) {
return Err(RuleParseError::BracketedNotIpv6 {
raw: inner.to_string(),
});
}
return Ok(Destination::Cidr(net));
}
let ip = IpAddr::from_str(inner).map_err(|_| RuleParseError::InvalidIp {
raw: inner.to_string(),
})?;
if !ip.is_ipv6() {
return Err(RuleParseError::BracketedNotIpv6 {
raw: inner.to_string(),
});
}
let net = IpNetwork::new(ip, 128).expect("128 is a valid IPv6 prefix");
Ok(Destination::Cidr(net))
}
fn looks_like_port(s: &str) -> bool {
if s.is_empty() {
return false;
}
if s.bytes().all(|b| b.is_ascii_digit()) && s.parse::<u16>().is_ok() {
return true;
}
if let Some((lo, hi)) = s.split_once('-')
&& !lo.is_empty()
&& !hi.is_empty()
&& lo.bytes().all(|b| b.is_ascii_digit())
&& hi.bytes().all(|b| b.is_ascii_digit())
&& lo.parse::<u16>().is_ok()
&& hi.parse::<u16>().is_ok()
{
return true;
}
false
}
fn group_from_keyword(s: &str) -> Option<DestinationGroup> {
match s {
"public" => Some(DestinationGroup::Public),
"private" => Some(DestinationGroup::Private),
"loopback" => Some(DestinationGroup::Loopback),
"link-local" => Some(DestinationGroup::LinkLocal),
"meta" => Some(DestinationGroup::Metadata),
"multicast" => Some(DestinationGroup::Multicast),
"host" => Some(DestinationGroup::Host),
_ => None,
}
}
fn parse_protocol(raw: &str) -> Result<Vec<Protocol>, RuleParseError> {
match raw {
"any" => Ok(Vec::new()),
"tcp" => Ok(vec![Protocol::Tcp]),
"udp" => Ok(vec![Protocol::Udp]),
"icmpv4" => Ok(vec![Protocol::Icmpv4]),
"icmpv6" => Ok(vec![Protocol::Icmpv6]),
other => Err(RuleParseError::InvalidProtocol {
raw: other.to_string(),
suggestion: SuggestionDisplay(suggest(other, PROTOCOL_KEYWORDS)),
}),
}
}
fn parse_ports(raw: &str) -> Result<Vec<PortRange>, RuleParseError> {
if raw == "any" {
return Ok(Vec::new());
}
if let Some((lo_raw, hi_raw)) = raw.split_once('-') {
let lo: u16 = lo_raw.parse().map_err(|_| RuleParseError::InvalidPorts {
raw: raw.to_string(),
})?;
let hi: u16 = hi_raw.parse().map_err(|_| RuleParseError::InvalidPorts {
raw: raw.to_string(),
})?;
if lo > hi {
return Err(RuleParseError::InvalidPortRange { lo, hi });
}
return Ok(vec![PortRange::range(lo, hi)]);
}
let port: u16 = raw.parse().map_err(|_| RuleParseError::InvalidPorts {
raw: raw.to_string(),
})?;
Ok(vec![PortRange::single(port)])
}
fn suggest(input: &str, keywords: &[&'static str]) -> Option<&'static str> {
let mut best: Option<(&'static str, usize)> = None;
for &kw in keywords {
let dist = levenshtein(input, kw);
if dist <= 2 && best.map(|(_, d)| dist < d).unwrap_or(true) {
best = Some((kw, dist));
}
}
best.map(|(kw, _)| kw)
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr: Vec<usize> = vec![0; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[track_caller]
fn assert_destination_matches(rule: &Rule, expected: &str) {
let actual = format!("{:?}", rule.destination);
assert!(
actual.contains(expected),
"expected destination to contain `{expected}`, got `{actual}`"
);
}
#[test]
fn allow_at_public_defaults_to_egress() {
let r = parse_rule_token("allow@public").unwrap();
assert!(matches!(r.action, Action::Allow));
assert!(matches!(r.direction, Direction::Egress));
assert!(matches!(
r.destination,
Destination::Group(DestinationGroup::Public)
));
assert!(r.protocols.is_empty());
assert!(r.ports.is_empty());
}
#[test]
fn deny_with_explicit_direction() {
let r = parse_rule_token("deny:any@host").unwrap();
assert!(matches!(r.action, Action::Deny));
assert!(matches!(r.direction, Direction::Any));
assert!(matches!(
r.destination,
Destination::Group(DestinationGroup::Host)
));
}
#[test]
fn allow_with_proto_and_port() {
let r = parse_rule_token("allow@public:tcp:443").unwrap();
assert_eq!(r.protocols, vec![Protocol::Tcp]);
assert_eq!(r.ports.len(), 1);
assert_eq!(r.ports[0].start, 443);
assert_eq!(r.ports[0].end, 443);
}
#[test]
fn allow_with_port_range() {
let r = parse_rule_token("allow@public:tcp:80-443").unwrap();
assert_eq!(r.ports.len(), 1);
assert_eq!(r.ports[0].start, 80);
assert_eq!(r.ports[0].end, 443);
}
#[test]
fn ip_target_becomes_cidr() {
let r = parse_rule_token("deny@198.51.100.5").unwrap();
match r.destination {
Destination::Cidr(net) => assert_eq!(net.to_string(), "198.51.100.5/32"),
other => panic!("expected /32 cidr, got {other:?}"),
}
}
#[test]
fn cidr_target_parses() {
let r = parse_rule_token("allow@10.0.0.0/8").unwrap();
match r.destination {
Destination::Cidr(net) => assert_eq!(net.to_string(), "10.0.0.0/8"),
other => panic!("expected cidr, got {other:?}"),
}
}
#[test]
fn domain_with_dot_auto_detects() {
let r = parse_rule_token("allow@example.com:tcp:443").unwrap();
assert_destination_matches(&r, "example.com");
}
#[test]
fn suffix_prefix_explicit() {
let r = parse_rule_token("allow@suffix=.local").unwrap();
match r.destination {
Destination::DomainSuffix(name) => assert_eq!(name.as_str(), "local"),
other => panic!("expected DomainSuffix, got {other:?}"),
}
}
#[test]
fn domain_prefix_escape_hatch() {
let r = parse_rule_token("allow@domain=public").unwrap();
match r.destination {
Destination::Domain(name) => assert_eq!(name.as_str(), "public"),
other => panic!("expected Domain, got {other:?}"),
}
}
#[test]
fn missing_at_errors() {
let err = parse_rule_token("allow public").unwrap_err();
assert!(matches!(err, RuleParseError::MissingAt { .. }), "{err}");
}
#[test]
fn invalid_action_suggests_close_keyword() {
let err = parse_rule_token("alow@public").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Did you mean `allow`?"),
"expected suggestion in `{msg}`"
);
}
#[test]
fn invalid_direction_suggests_close_keyword() {
let err = parse_rule_token("allow:iingress@public").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Did you mean `ingress`?"),
"expected suggestion in `{msg}`"
);
}
#[test]
fn ambiguous_bare_token_suggests_group() {
let err = parse_rule_token("allow@piublic").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Did you mean `public`?"),
"expected suggestion in `{msg}`"
);
}
#[test]
fn invalid_protocol_suggests_close_keyword() {
let err = parse_rule_token("allow@public:tpc:443").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Did you mean `tcp`?"),
"expected suggestion in `{msg}`"
);
}
#[test]
fn icmp_in_ingress_is_rejected() {
let err = parse_rule_token("allow:ingress@public:icmpv4:any").unwrap_err();
assert!(
matches!(err, RuleParseError::IngressDoesNotSupportIcmp),
"{err}"
);
}
#[test]
fn icmp_in_any_direction_is_rejected() {
let err = parse_rule_token("allow:any@public:icmpv6").unwrap_err();
assert!(
matches!(err, RuleParseError::IngressDoesNotSupportIcmp),
"{err}"
);
}
#[test]
fn icmp_in_egress_is_allowed() {
let r = parse_rule_token("allow:egress@public:icmpv4").unwrap();
assert_eq!(r.protocols, vec![Protocol::Icmpv4]);
}
#[test]
fn invalid_port_range_lo_gt_hi_rejected() {
let err = parse_rule_token("allow@public:tcp:443-80").unwrap_err();
assert!(
matches!(err, RuleParseError::InvalidPortRange { lo: 443, hi: 80 }),
"{err}"
);
}
#[test]
fn parse_rule_list_preserves_order() {
let rules = parse_rule_list("deny@198.51.100.5,allow@public").unwrap();
assert_eq!(rules.len(), 2);
assert!(matches!(rules[0].action, Action::Deny));
assert!(matches!(rules[1].action, Action::Allow));
}
#[test]
fn parse_rule_list_skips_empty_segments() {
let rules = parse_rule_list("allow@public,, allow@private").unwrap();
assert_eq!(rules.len(), 2);
}
#[test]
fn trailing_junk_rejected() {
let err = parse_rule_token("allow@public:tcp:443:extra").unwrap_err();
assert!(matches!(err, RuleParseError::TrailingJunk { .. }), "{err}");
}
#[test]
fn bracketed_ipv6_parses() {
let r = parse_rule_token("allow@[2001:db8::1]").unwrap();
match r.destination {
Destination::Cidr(net) => assert_eq!(net.to_string(), "2001:db8::1/128"),
other => panic!("expected /128 cidr, got {other:?}"),
}
assert!(r.protocols.is_empty());
assert!(r.ports.is_empty());
}
#[test]
fn bracketed_ipv6_with_proto_and_port() {
let r = parse_rule_token("allow@[2001:db8::1]:tcp:443").unwrap();
match &r.destination {
Destination::Cidr(net) => assert_eq!(net.to_string(), "2001:db8::1/128"),
other => panic!("expected /128 cidr, got {other:?}"),
}
assert_eq!(r.protocols, vec![Protocol::Tcp]);
assert_eq!(r.ports.len(), 1);
assert_eq!(r.ports[0].start, 443);
assert_eq!(r.ports[0].end, 443);
}
#[test]
fn bracketed_ipv6_cidr_parses() {
let r = parse_rule_token("deny@[2001:db8::/32]:tcp").unwrap();
match &r.destination {
Destination::Cidr(net) => assert_eq!(net.to_string(), "2001:db8::/32"),
other => panic!("expected ipv6 cidr, got {other:?}"),
}
}
#[test]
fn bracketed_loopback_ipv6_parses() {
let r = parse_rule_token("allow@[::1]:tcp:22").unwrap();
match &r.destination {
Destination::Cidr(net) => assert_eq!(net.to_string(), "::1/128"),
other => panic!("expected /128 cidr, got {other:?}"),
}
}
#[test]
fn bracketed_ipv4_rejected() {
let err = parse_rule_token("allow@[192.0.2.1]:tcp:443").unwrap_err();
assert!(
matches!(err, RuleParseError::BracketedNotIpv6 { .. }),
"{err}"
);
}
#[test]
fn bracketed_domain_rejected() {
let err = parse_rule_token("allow@[example.com]:tcp:443").unwrap_err();
assert!(
matches!(
err,
RuleParseError::InvalidIp { .. } | RuleParseError::BracketedNotIpv6 { .. }
),
"{err}"
);
}
#[test]
fn unclosed_bracket_rejected() {
let err = parse_rule_token("allow@[2001:db8::1:tcp:443").unwrap_err();
assert!(
matches!(err, RuleParseError::UnclosedBracket { .. }),
"{err}"
);
}
#[test]
fn unexpected_after_bracket_rejected() {
let err = parse_rule_token("allow@[2001:db8::1]xyz").unwrap_err();
assert!(
matches!(err, RuleParseError::UnexpectedAfterBracket { .. }),
"{err}"
);
}
#[test]
fn port_in_proto_slot_helpful_error() {
let err = parse_rule_token("allow@example.com:443").unwrap_err();
match &err {
RuleParseError::PortInProtocolSlot { target, value } => {
assert_eq!(target, "example.com");
assert_eq!(value, "443");
}
other => panic!("expected PortInProtocolSlot, got {other:?}"),
}
let msg = err.to_string();
assert!(
msg.contains("example.com:tcp:443"),
"expected suggestion in `{msg}`"
);
}
#[test]
fn port_range_in_proto_slot_helpful_error() {
let err = parse_rule_token("allow@example.com:80-443").unwrap_err();
assert!(
matches!(err, RuleParseError::PortInProtocolSlot { .. }),
"{err}"
);
}
#[test]
fn bare_ipv6_hits_trailing_junk_with_bracket_hint() {
let err = parse_rule_token("allow@2001:db8::1").unwrap_err();
let msg = err.to_string();
assert!(matches!(err, RuleParseError::TrailingJunk { .. }), "{err}");
assert!(msg.contains("[<addr>]"), "expected bracket hint in `{msg}`");
}
#[test]
fn levenshtein_basic() {
assert_eq!(levenshtein("public", "public"), 0);
assert_eq!(levenshtein("piublic", "public"), 1);
assert_eq!(levenshtein("iingress", "ingress"), 1);
assert!(levenshtein("totally-different", "tcp") > 5);
}
#[test]
fn suggest_returns_none_when_too_far() {
assert_eq!(suggest("xyz", &["public", "private"]), None);
}
#[test]
fn suggest_returns_closest_within_distance_two() {
assert_eq!(
suggest("piublic", &["public", "private", "loopback"]),
Some("public")
);
}
}