use std::str::FromStr;
use ipnetwork::IpNetwork;
use super::{
Action, Destination, DestinationGroup, Direction, DomainName, DomainNameError, NetworkPolicy,
PortRange, Protocol, Rule,
};
#[derive(Debug, Clone, thiserror::Error)]
pub enum BuildError {
#[error(
"rule #{rule_index}: direction not set; call .egress(), .ingress(), or .any() before the rule-adder"
)]
DirectionNotSet { rule_index: usize },
#[error(
"rule #{rule_index}: destination not set; call .ip(), .cidr(), .domain(), .domain_suffix(), .group(), or .any() on the rule-destination builder"
)]
MissingDestination { rule_index: usize },
#[error("rule #{rule_index}: invalid IP address `{raw}`")]
InvalidIp { rule_index: usize, raw: String },
#[error("rule #{rule_index}: invalid CIDR `{raw}`")]
InvalidCidr { rule_index: usize, raw: String },
#[error("rule #{rule_index}: invalid domain `{raw}`: {source}")]
InvalidDomain {
rule_index: usize,
raw: String,
#[source]
source: DomainNameError,
},
#[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
#[error(
"rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
)]
IngressDoesNotSupportIcmp { rule_index: usize },
}
#[derive(Debug, Default)]
pub struct NetworkPolicyBuilder {
default_egress: Option<Action>,
default_ingress: Option<Action>,
pending_rules: Vec<PendingRule>,
errors: Vec<BuildError>,
}
impl NetworkPolicyBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn default_allow(mut self) -> Self {
self.default_egress = Some(Action::Allow);
self.default_ingress = Some(Action::Allow);
self
}
pub fn default_deny(mut self) -> Self {
self.default_egress = Some(Action::Deny);
self.default_ingress = Some(Action::Deny);
self
}
pub fn default_egress(mut self, action: Action) -> Self {
self.default_egress = Some(action);
self
}
pub fn default_ingress(mut self, action: Action) -> Self {
self.default_ingress = Some(action);
self
}
pub fn rule<F>(self, f: F) -> Self
where
F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
{
self.with_rule_builder(None, f)
}
pub fn egress<F>(self, f: F) -> Self
where
F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
{
self.with_rule_builder(Some(Direction::Egress), f)
}
pub fn ingress<F>(self, f: F) -> Self
where
F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
{
self.with_rule_builder(Some(Direction::Ingress), f)
}
pub fn any<F>(self, f: F) -> Self
where
F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
{
self.with_rule_builder(Some(Direction::Any), f)
}
fn with_rule_builder<F>(mut self, initial_direction: Option<Direction>, f: F) -> Self
where
F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
{
let mut rb = RuleBuilder {
direction: initial_direction,
protocols: Vec::new(),
ports: Vec::new(),
pending_rules: Vec::new(),
errors: Vec::new(),
};
let _ = f(&mut rb);
self.pending_rules.append(&mut rb.pending_rules);
self.errors.append(&mut rb.errors);
self
}
pub fn build(self) -> Result<NetworkPolicy, BuildError> {
if let Some(err) = self.errors.into_iter().next() {
return Err(err);
}
let mut rules = Vec::with_capacity(self.pending_rules.len());
for (idx, pending) in self.pending_rules.into_iter().enumerate() {
let direction = pending
.direction
.ok_or(BuildError::DirectionNotSet { rule_index: idx })?;
let destination = pending.destination.parse(idx)?;
if matches!(direction, Direction::Ingress | Direction::Any)
&& pending
.protocols
.iter()
.any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
{
return Err(BuildError::IngressDoesNotSupportIcmp { rule_index: idx });
}
rules.push(Rule {
direction,
destination,
protocols: pending.protocols,
ports: pending.ports,
action: pending.action,
});
}
warn_about_shadows(&rules);
Ok(NetworkPolicy {
default_egress: self.default_egress.unwrap_or_else(default_egress_default),
default_ingress: self.default_ingress.unwrap_or_else(default_ingress_default),
rules,
})
}
}
fn default_egress_default() -> Action {
Action::Deny
}
fn default_ingress_default() -> Action {
Action::Allow
}
#[derive(Debug)]
pub struct RuleBuilder {
direction: Option<Direction>,
protocols: Vec<Protocol>,
ports: Vec<PortRange>,
pending_rules: Vec<PendingRule>,
errors: Vec<BuildError>,
}
impl RuleBuilder {
pub fn egress(&mut self) -> &mut Self {
self.direction = Some(Direction::Egress);
self
}
pub fn ingress(&mut self) -> &mut Self {
self.direction = Some(Direction::Ingress);
self
}
pub fn any(&mut self) -> &mut Self {
self.direction = Some(Direction::Any);
self
}
pub fn tcp(&mut self) -> &mut Self {
self.add_protocol(Protocol::Tcp)
}
pub fn udp(&mut self) -> &mut Self {
self.add_protocol(Protocol::Udp)
}
pub fn icmpv4(&mut self) -> &mut Self {
self.add_protocol(Protocol::Icmpv4)
}
pub fn icmpv6(&mut self) -> &mut Self {
self.add_protocol(Protocol::Icmpv6)
}
fn add_protocol(&mut self, p: Protocol) -> &mut Self {
if !self.protocols.contains(&p) {
self.protocols.push(p);
}
self
}
pub fn port(&mut self, port: u16) -> &mut Self {
let pr = PortRange::single(port);
if !self.ports.contains(&pr) {
self.ports.push(pr);
}
self
}
pub fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self {
if lo > hi {
self.errors.push(BuildError::InvalidPortRange {
rule_index: self.pending_rules.len(),
lo,
hi,
});
return self;
}
let pr = PortRange::range(lo, hi);
if !self.ports.contains(&pr) {
self.ports.push(pr);
}
self
}
pub fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self {
for p in ports {
self.port(p);
}
self
}
pub fn allow_public(&mut self) -> &mut Self {
self.commit_group(Action::Allow, DestinationGroup::Public)
}
pub fn deny_public(&mut self) -> &mut Self {
self.commit_group(Action::Deny, DestinationGroup::Public)
}
pub fn allow_private(&mut self) -> &mut Self {
self.commit_group(Action::Allow, DestinationGroup::Private)
}
pub fn deny_private(&mut self) -> &mut Self {
self.commit_group(Action::Deny, DestinationGroup::Private)
}
pub fn allow_loopback(&mut self) -> &mut Self {
self.commit_group(Action::Allow, DestinationGroup::Loopback)
}
pub fn deny_loopback(&mut self) -> &mut Self {
self.commit_group(Action::Deny, DestinationGroup::Loopback)
}
pub fn allow_link_local(&mut self) -> &mut Self {
self.commit_group(Action::Allow, DestinationGroup::LinkLocal)
}
pub fn deny_link_local(&mut self) -> &mut Self {
self.commit_group(Action::Deny, DestinationGroup::LinkLocal)
}
pub fn allow_meta(&mut self) -> &mut Self {
self.commit_group(Action::Allow, DestinationGroup::Metadata)
}
pub fn deny_meta(&mut self) -> &mut Self {
self.commit_group(Action::Deny, DestinationGroup::Metadata)
}
pub fn allow_multicast(&mut self) -> &mut Self {
self.commit_group(Action::Allow, DestinationGroup::Multicast)
}
pub fn deny_multicast(&mut self) -> &mut Self {
self.commit_group(Action::Deny, DestinationGroup::Multicast)
}
pub fn allow_host(&mut self) -> &mut Self {
self.commit_group(Action::Allow, DestinationGroup::Host)
}
pub fn deny_host(&mut self) -> &mut Self {
self.commit_group(Action::Deny, DestinationGroup::Host)
}
pub fn allow_local(&mut self) -> &mut Self {
self.allow_loopback();
self.allow_link_local();
self.allow_host();
self
}
pub fn deny_local(&mut self) -> &mut Self {
self.deny_loopback();
self.deny_link_local();
self.deny_host();
self
}
pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for name in names {
self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
}
self
}
pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for name in names {
self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
}
self
}
pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for suffix in suffixes {
self.commit_rule(
Action::Allow,
PendingDestination::DomainSuffix(suffix.into()),
);
}
self
}
pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for suffix in suffixes {
self.commit_rule(
Action::Deny,
PendingDestination::DomainSuffix(suffix.into()),
);
}
self
}
pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
RuleDestinationBuilder {
rule_builder: self,
action: Action::Allow,
}
}
pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
RuleDestinationBuilder {
rule_builder: self,
action: Action::Deny,
}
}
fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
self.commit_rule(
action,
PendingDestination::Resolved(Destination::Group(group)),
);
self
}
fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
self.pending_rules.push(PendingRule {
direction: self.direction,
destination,
protocols: self.protocols.clone(),
ports: self.ports.clone(),
action,
});
}
}
#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
pub struct RuleDestinationBuilder<'a> {
rule_builder: &'a mut RuleBuilder,
action: Action,
}
impl<'a> RuleDestinationBuilder<'a> {
pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
self.rule_builder
.commit_rule(self.action, PendingDestination::Ip(ip.into()));
self.rule_builder
}
pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
self.rule_builder
.commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
self.rule_builder
}
pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
self.rule_builder
.commit_rule(self.action, PendingDestination::Domain(domain.into()));
self.rule_builder
}
pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
self.rule_builder
.commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
self.rule_builder
}
pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
self.rule_builder.commit_rule(
self.action,
PendingDestination::Resolved(Destination::Group(group)),
);
self.rule_builder
}
pub fn any(self) -> &'a mut RuleBuilder {
self.rule_builder
.commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
self.rule_builder
}
}
#[derive(Debug, Clone)]
struct PendingRule {
direction: Option<Direction>,
destination: PendingDestination,
protocols: Vec<Protocol>,
ports: Vec<PortRange>,
action: Action,
}
#[derive(Debug, Clone)]
enum PendingDestination {
Resolved(Destination),
Ip(String),
Cidr(String),
Domain(String),
DomainSuffix(String),
}
impl PendingDestination {
fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
match self {
PendingDestination::Resolved(d) => Ok(d.clone()),
PendingDestination::Ip(raw) => {
let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
rule_index: idx,
raw: raw.clone(),
})?;
let prefix = if ip.is_ipv4() { 32 } else { 128 };
let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
rule_index: idx,
raw: raw.clone(),
})?;
Ok(Destination::Cidr(net))
}
PendingDestination::Cidr(raw) => {
let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
rule_index: idx,
raw: raw.clone(),
})?;
Ok(Destination::Cidr(net))
}
PendingDestination::Domain(raw) => {
let name =
DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
rule_index: idx,
raw: raw.clone(),
source,
})?;
Ok(Destination::Domain(name))
}
PendingDestination::DomainSuffix(raw) => {
let name =
DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
rule_index: idx,
raw: raw.clone(),
source,
})?;
Ok(Destination::DomainSuffix(name))
}
}
}
}
fn warn_about_shadows(rules: &[Rule]) {
for (i, later) in rules.iter().enumerate() {
for (j, earlier) in rules.iter().take(i).enumerate() {
if shadows(earlier, later) {
tracing::warn!(
shadowed_index = i,
shadowed_by = j,
"rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
later.direction,
later.action,
later.destination,
earlier.direction,
earlier.action,
earlier.destination,
);
}
}
}
}
fn shadows(earlier: &Rule, later: &Rule) -> bool {
direction_covers(earlier.direction, later.direction)
&& destination_covers(&earlier.destination, &later.destination)
&& protocol_set_covers(&earlier.protocols, &later.protocols)
&& port_set_covers(&earlier.ports, &later.ports)
}
fn direction_covers(earlier: Direction, later: Direction) -> bool {
matches!(
(earlier, later),
(Direction::Any, _)
| (Direction::Egress, Direction::Egress)
| (Direction::Ingress, Direction::Ingress)
)
}
fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
match (earlier, later) {
(Destination::Any, _) => true,
(Destination::Group(eg), Destination::Group(lg)) => eg == lg,
(Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
_ => false,
}
}
fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
match (outer, inner) {
(IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
(IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
_ => false,
}
}
fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
if earlier.is_empty() {
return true; }
if later.is_empty() {
return false; }
later.iter().all(|p| earlier.contains(p))
}
fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
if earlier.is_empty() {
return true;
}
if later.is_empty() {
return false;
}
later.iter().all(|lp| {
earlier
.iter()
.any(|ep| ep.start <= lp.start && lp.end <= ep.end)
})
}
impl NetworkPolicy {
pub fn builder() -> NetworkPolicyBuilder {
NetworkPolicyBuilder::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_builder_yields_asymmetric_default() {
let p = NetworkPolicy::builder().build().unwrap();
assert!(matches!(p.default_egress, Action::Deny));
assert!(matches!(p.default_ingress, Action::Allow));
assert!(p.rules.is_empty());
}
#[test]
fn defaults_set_and_override() {
let p = NetworkPolicy::builder()
.default_deny()
.default_ingress(Action::Allow)
.build()
.unwrap();
assert!(matches!(p.default_egress, Action::Deny));
assert!(matches!(p.default_ingress, Action::Allow));
}
#[test]
fn egress_closure_commits_one_rule_per_shortcut() {
let p = NetworkPolicy::builder()
.egress(|e| e.tcp().port(443).allow_public().allow_private())
.build()
.unwrap();
assert_eq!(p.rules.len(), 2);
assert!(matches!(p.rules[0].direction, Direction::Egress));
assert!(matches!(p.rules[0].action, Action::Allow));
assert!(matches!(
p.rules[0].destination,
Destination::Group(DestinationGroup::Public)
));
assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
assert_eq!(p.rules[0].ports.len(), 1);
assert!(matches!(
p.rules[1].destination,
Destination::Group(DestinationGroup::Private)
));
}
#[test]
fn allow_local_expands_to_three_groups() {
let p = NetworkPolicy::builder()
.egress(|e| e.allow_local())
.build()
.unwrap();
assert_eq!(p.rules.len(), 3);
let groups: Vec<_> = p
.rules
.iter()
.map(|r| match &r.destination {
Destination::Group(g) => *g,
other => panic!("unexpected destination {other:?}"),
})
.collect();
assert_eq!(
groups,
vec![
DestinationGroup::Loopback,
DestinationGroup::LinkLocal,
DestinationGroup::Host,
]
);
}
#[test]
fn explicit_ip_parses_at_build() {
let p = NetworkPolicy::builder()
.any(|a| a.deny().ip("198.51.100.5"))
.build()
.unwrap();
assert_eq!(p.rules.len(), 1);
assert!(matches!(p.rules[0].direction, Direction::Any));
assert!(matches!(p.rules[0].action, Action::Deny));
match &p.rules[0].destination {
Destination::Cidr(net) => {
assert_eq!(net.to_string(), "198.51.100.5/32");
}
other => panic!("expected Cidr, got {other:?}"),
}
}
#[test]
fn invalid_ip_surfaces_at_build() {
let result = NetworkPolicy::builder()
.egress(|e| e.allow().ip("not-an-ip"))
.build();
match result {
Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
assert_eq!(raw, "not-an-ip");
}
other => panic!("expected InvalidIp, got {other:?}"),
}
}
#[test]
fn domain_parses_to_canonical_form() {
let p = NetworkPolicy::builder()
.egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
.build()
.unwrap();
match &p.rules[0].destination {
Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
other => panic!("expected Domain, got {other:?}"),
}
}
#[test]
fn invalid_port_range_surfaces_at_build() {
let result = NetworkPolicy::builder()
.egress(|e| e.tcp().port_range(443, 80).allow_public())
.build();
match result {
Err(BuildError::InvalidPortRange {
lo: 443, hi: 80, ..
}) => {}
other => panic!("expected InvalidPortRange, got {other:?}"),
}
}
#[test]
fn missing_direction_surfaces_at_build() {
let result = NetworkPolicy::builder()
.rule(|r| r.tcp().port(443).allow_public())
.build();
match result {
Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
other => panic!("expected DirectionNotSet, got {other:?}"),
}
}
#[test]
fn icmp_in_ingress_rejected_at_build() {
let result = NetworkPolicy::builder()
.ingress(|i| i.icmpv4().allow_public())
.build();
match result {
Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
}
}
#[test]
fn icmp_in_any_direction_rejected_at_build() {
let result = NetworkPolicy::builder()
.any(|a| a.icmpv6().allow_public())
.build();
match result {
Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
}
}
#[test]
fn duplicate_protocols_dedupe() {
let p = NetworkPolicy::builder()
.egress(|e| e.tcp().tcp().udp().tcp().allow_public())
.build()
.unwrap();
assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
}
#[test]
fn explicit_group_uses_typed_argument() {
let p = NetworkPolicy::builder()
.egress(|e| e.allow().group(DestinationGroup::Multicast))
.build()
.unwrap();
assert!(matches!(
p.rules[0].destination,
Destination::Group(DestinationGroup::Multicast)
));
}
#[test]
fn chain_form_compiles_without_explicit_return() {
let _ = NetworkPolicy::builder()
.rule(|r| r.egress().tcp().allow_public())
.build()
.unwrap();
}
#[test]
fn shadowed_rule_builds_and_is_detected() {
let broader = Rule {
direction: Direction::Egress,
destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
protocols: vec![],
ports: vec![],
action: Action::Allow,
};
let narrower = Rule {
direction: Direction::Egress,
destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
protocols: vec![],
ports: vec![],
action: Action::Allow,
};
assert!(
shadows(&broader, &narrower),
"10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
);
assert!(
!shadows(&narrower, &broader),
"10.0.0.5/32 should NOT shadow 10.0.0.0/8"
);
let _ = NetworkPolicy::builder()
.egress(|e| e.allow().cidr("10.0.0.0/8"))
.egress(|e| e.allow().cidr("10.0.0.5/32"))
.build()
.unwrap();
}
#[test]
fn direction_cover_relations() {
use Direction::*;
assert!(direction_covers(Any, Egress));
assert!(direction_covers(Any, Ingress));
assert!(direction_covers(Any, Any));
assert!(direction_covers(Egress, Egress));
assert!(!direction_covers(Egress, Ingress));
assert!(!direction_covers(Egress, Any)); assert!(direction_covers(Ingress, Ingress));
assert!(!direction_covers(Ingress, Egress));
assert!(!direction_covers(Ingress, Any));
}
#[test]
fn deny_domains_produces_one_rule_per_name() {
let p = NetworkPolicy::builder()
.default_allow()
.egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
.build()
.unwrap();
assert_eq!(p.rules.len(), 2);
for rule in &p.rules {
assert_eq!(rule.action, Action::Deny);
assert_eq!(rule.direction, Direction::Egress);
assert!(rule.protocols.is_empty(), "no protocol filter");
assert!(rule.ports.is_empty(), "no port filter");
}
assert!(matches!(
&p.rules[0].destination,
Destination::Domain(d) if d.as_str() == "evil.com",
));
assert!(matches!(
&p.rules[1].destination,
Destination::Domain(d) if d.as_str() == "tracker.example",
));
}
#[test]
fn deny_domain_suffixes_produces_one_rule_per_suffix() {
let p = NetworkPolicy::builder()
.default_allow()
.egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
.build()
.unwrap();
assert_eq!(p.rules.len(), 2);
assert!(matches!(
&p.rules[0].destination,
Destination::DomainSuffix(d) if d.as_str() == "ads.example",
));
assert!(matches!(
&p.rules[1].destination,
Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
));
}
#[test]
fn deny_domains_inherits_protocol_and_port_filter() {
let p = NetworkPolicy::builder()
.default_allow()
.egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
.build()
.unwrap();
assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
}
#[test]
fn allow_domains_produces_allow_rules() {
let p = NetworkPolicy::builder()
.default_deny()
.egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
.build()
.unwrap();
assert_eq!(p.rules.len(), 2);
for rule in &p.rules {
assert_eq!(rule.action, Action::Allow);
}
}
#[test]
fn deny_domains_empty_input_is_noop() {
let p = NetworkPolicy::builder()
.default_allow()
.egress(|e| e.deny_domains(Vec::<&str>::new()))
.build()
.unwrap();
assert!(p.rules.is_empty());
}
#[test]
fn deny_domains_invalid_input_surfaces_at_build() {
let result = NetworkPolicy::builder()
.default_allow()
.egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
.build();
match result {
Err(BuildError::InvalidDomain {
raw, rule_index, ..
}) => {
assert_eq!(raw, "not a domain!");
assert_eq!(rule_index, 1);
}
other => panic!("expected InvalidDomain, got {other:?}"),
}
}
}