1use std::str::FromStr;
33
34use ipnetwork::IpNetwork;
35
36use super::{
37 Action, Destination, DestinationGroup, Direction, DomainName, DomainNameError, NetworkPolicy,
38 PortRange, Protocol, Rule,
39};
40
41#[derive(Debug, Clone, thiserror::Error)]
55pub enum BuildError {
56 #[error(
58 "rule #{rule_index}: direction not set; call .egress(), .ingress(), or .any() before the rule-adder"
59 )]
60 DirectionNotSet { rule_index: usize },
61
62 #[error(
65 "rule #{rule_index}: destination not set; call .ip(), .cidr(), .domain(), .domain_suffix(), .group(), or .any() on the rule-destination builder"
66 )]
67 MissingDestination { rule_index: usize },
68
69 #[error("rule #{rule_index}: invalid IP address `{raw}`")]
72 InvalidIp { rule_index: usize, raw: String },
73
74 #[error("rule #{rule_index}: invalid CIDR `{raw}`")]
76 InvalidCidr { rule_index: usize, raw: String },
77
78 #[error("invalid IPv4 pool `{raw}`: prefix must be /30 or shorter")]
80 InvalidIpv4Pool { raw: String },
81
82 #[error("invalid IPv6 pool `{raw}`: prefix must be /64 or shorter")]
84 InvalidIpv6Pool { raw: String },
85
86 #[error("rule #{rule_index}: invalid domain `{raw}`: {source}")]
89 InvalidDomain {
90 rule_index: usize,
91 raw: String,
92 #[source]
93 source: DomainNameError,
94 },
95
96 #[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
98 InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
99
100 #[error(
104 "rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
105 )]
106 IngressDoesNotSupportIcmp { rule_index: usize },
107}
108
109#[derive(Debug, Default)]
117pub struct NetworkPolicyBuilder {
118 default_egress: Option<Action>,
119 default_ingress: Option<Action>,
120 pending_rules: Vec<PendingRule>,
121 errors: Vec<BuildError>,
122}
123
124impl NetworkPolicyBuilder {
125 pub fn new() -> Self {
127 Self::default()
128 }
129
130 pub fn default_allow(mut self) -> Self {
132 self.default_egress = Some(Action::Allow);
133 self.default_ingress = Some(Action::Allow);
134 self
135 }
136
137 pub fn default_deny(mut self) -> Self {
139 self.default_egress = Some(Action::Deny);
140 self.default_ingress = Some(Action::Deny);
141 self
142 }
143
144 pub fn default_egress(mut self, action: Action) -> Self {
146 self.default_egress = Some(action);
147 self
148 }
149
150 pub fn default_ingress(mut self, action: Action) -> Self {
152 self.default_ingress = Some(action);
153 self
154 }
155
156 pub fn rule<F>(self, f: F) -> Self
159 where
160 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
161 {
162 self.with_rule_builder(None, f)
163 }
164
165 pub fn egress<F>(self, f: F) -> Self
167 where
168 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
169 {
170 self.with_rule_builder(Some(Direction::Egress), f)
171 }
172
173 pub fn ingress<F>(self, f: F) -> Self
175 where
176 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
177 {
178 self.with_rule_builder(Some(Direction::Ingress), f)
179 }
180
181 pub fn any<F>(self, f: F) -> Self
184 where
185 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
186 {
187 self.with_rule_builder(Some(Direction::Any), f)
188 }
189
190 fn with_rule_builder<F>(mut self, initial_direction: Option<Direction>, f: F) -> Self
191 where
192 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
193 {
194 let mut rb = RuleBuilder {
195 direction: initial_direction,
196 protocols: Vec::new(),
197 ports: Vec::new(),
198 pending_rules: Vec::new(),
199 errors: Vec::new(),
200 };
201 let _ = f(&mut rb);
202 self.pending_rules.append(&mut rb.pending_rules);
203 self.errors.append(&mut rb.errors);
204 self
205 }
206
207 pub fn build(self) -> Result<NetworkPolicy, BuildError> {
216 if let Some(err) = self.errors.into_iter().next() {
217 return Err(err);
218 }
219
220 let mut rules = Vec::with_capacity(self.pending_rules.len());
221 for (idx, pending) in self.pending_rules.into_iter().enumerate() {
222 let direction = pending
223 .direction
224 .ok_or(BuildError::DirectionNotSet { rule_index: idx })?;
225 let destination = pending.destination.parse(idx)?;
226
227 if matches!(direction, Direction::Ingress | Direction::Any)
228 && pending
229 .protocols
230 .iter()
231 .any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
232 {
233 return Err(BuildError::IngressDoesNotSupportIcmp { rule_index: idx });
234 }
235
236 rules.push(Rule {
237 direction,
238 destination,
239 protocols: pending.protocols,
240 ports: pending.ports,
241 action: pending.action,
242 });
243 }
244
245 warn_about_shadows(&rules);
246
247 Ok(NetworkPolicy {
248 default_egress: self.default_egress.unwrap_or_else(default_egress_default),
249 default_ingress: self.default_ingress.unwrap_or_else(default_ingress_default),
250 rules,
251 })
252 }
253}
254
255fn default_egress_default() -> Action {
259 Action::Deny
260}
261
262fn default_ingress_default() -> Action {
266 Action::Allow
267}
268
269#[derive(Debug)]
279pub struct RuleBuilder {
280 direction: Option<Direction>,
281 protocols: Vec<Protocol>,
282 ports: Vec<PortRange>,
283 pending_rules: Vec<PendingRule>,
284 errors: Vec<BuildError>,
285}
286
287impl RuleBuilder {
288 pub fn egress(&mut self) -> &mut Self {
292 self.direction = Some(Direction::Egress);
293 self
294 }
295
296 pub fn ingress(&mut self) -> &mut Self {
298 self.direction = Some(Direction::Ingress);
299 self
300 }
301
302 pub fn any(&mut self) -> &mut Self {
305 self.direction = Some(Direction::Any);
306 self
307 }
308
309 pub fn tcp(&mut self) -> &mut Self {
313 self.add_protocol(Protocol::Tcp)
314 }
315
316 pub fn udp(&mut self) -> &mut Self {
318 self.add_protocol(Protocol::Udp)
319 }
320
321 pub fn icmpv4(&mut self) -> &mut Self {
325 self.add_protocol(Protocol::Icmpv4)
326 }
327
328 pub fn icmpv6(&mut self) -> &mut Self {
330 self.add_protocol(Protocol::Icmpv6)
331 }
332
333 fn add_protocol(&mut self, p: Protocol) -> &mut Self {
334 if !self.protocols.contains(&p) {
335 self.protocols.push(p);
336 }
337 self
338 }
339
340 pub fn port(&mut self, port: u16) -> &mut Self {
344 let pr = PortRange::single(port);
345 if !self.ports.contains(&pr) {
346 self.ports.push(pr);
347 }
348 self
349 }
350
351 pub fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self {
354 if lo > hi {
355 self.errors.push(BuildError::InvalidPortRange {
356 rule_index: self.pending_rules.len(),
357 lo,
358 hi,
359 });
360 return self;
361 }
362 let pr = PortRange::range(lo, hi);
363 if !self.ports.contains(&pr) {
364 self.ports.push(pr);
365 }
366 self
367 }
368
369 pub fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self {
372 for p in ports {
373 self.port(p);
374 }
375 self
376 }
377
378 pub fn allow_public(&mut self) -> &mut Self {
382 self.commit_group(Action::Allow, DestinationGroup::Public)
383 }
384
385 pub fn deny_public(&mut self) -> &mut Self {
387 self.commit_group(Action::Deny, DestinationGroup::Public)
388 }
389
390 pub fn allow_private(&mut self) -> &mut Self {
392 self.commit_group(Action::Allow, DestinationGroup::Private)
393 }
394
395 pub fn deny_private(&mut self) -> &mut Self {
397 self.commit_group(Action::Deny, DestinationGroup::Private)
398 }
399
400 pub fn allow_loopback(&mut self) -> &mut Self {
409 self.commit_group(Action::Allow, DestinationGroup::Loopback)
410 }
411
412 pub fn deny_loopback(&mut self) -> &mut Self {
420 self.commit_group(Action::Deny, DestinationGroup::Loopback)
421 }
422
423 pub fn allow_link_local(&mut self) -> &mut Self {
427 self.commit_group(Action::Allow, DestinationGroup::LinkLocal)
428 }
429
430 pub fn deny_link_local(&mut self) -> &mut Self {
432 self.commit_group(Action::Deny, DestinationGroup::LinkLocal)
433 }
434
435 pub fn allow_meta(&mut self) -> &mut Self {
438 self.commit_group(Action::Allow, DestinationGroup::Metadata)
439 }
440
441 pub fn deny_meta(&mut self) -> &mut Self {
443 self.commit_group(Action::Deny, DestinationGroup::Metadata)
444 }
445
446 pub fn allow_multicast(&mut self) -> &mut Self {
448 self.commit_group(Action::Allow, DestinationGroup::Multicast)
449 }
450
451 pub fn deny_multicast(&mut self) -> &mut Self {
453 self.commit_group(Action::Deny, DestinationGroup::Multicast)
454 }
455
456 pub fn allow_host(&mut self) -> &mut Self {
461 self.commit_group(Action::Allow, DestinationGroup::Host)
462 }
463
464 pub fn deny_host(&mut self) -> &mut Self {
466 self.commit_group(Action::Deny, DestinationGroup::Host)
467 }
468
469 pub fn allow_local(&mut self) -> &mut Self {
482 self.allow_loopback();
483 self.allow_link_local();
484 self.allow_host();
485 self
486 }
487
488 pub fn deny_local(&mut self) -> &mut Self {
491 self.deny_loopback();
492 self.deny_link_local();
493 self.deny_host();
494 self
495 }
496
497 pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
501 where
502 I: IntoIterator<Item = S>,
503 S: Into<String>,
504 {
505 for name in names {
506 self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
507 }
508 self
509 }
510
511 pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
513 where
514 I: IntoIterator<Item = S>,
515 S: Into<String>,
516 {
517 for name in names {
518 self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
519 }
520 self
521 }
522
523 pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
525 where
526 I: IntoIterator<Item = S>,
527 S: Into<String>,
528 {
529 for suffix in suffixes {
530 self.commit_rule(
531 Action::Allow,
532 PendingDestination::DomainSuffix(suffix.into()),
533 );
534 }
535 self
536 }
537
538 pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
540 where
541 I: IntoIterator<Item = S>,
542 S: Into<String>,
543 {
544 for suffix in suffixes {
545 self.commit_rule(
546 Action::Deny,
547 PendingDestination::DomainSuffix(suffix.into()),
548 );
549 }
550 self
551 }
552
553 pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
560 RuleDestinationBuilder {
561 rule_builder: self,
562 action: Action::Allow,
563 }
564 }
565
566 pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
568 RuleDestinationBuilder {
569 rule_builder: self,
570 action: Action::Deny,
571 }
572 }
573
574 fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
577 self.commit_rule(
578 action,
579 PendingDestination::Resolved(Destination::Group(group)),
580 );
581 self
582 }
583
584 fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
585 self.pending_rules.push(PendingRule {
586 direction: self.direction,
587 destination,
588 protocols: self.protocols.clone(),
589 ports: self.ports.clone(),
590 action,
591 });
592 }
593}
594
595#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
605pub struct RuleDestinationBuilder<'a> {
606 rule_builder: &'a mut RuleBuilder,
607 action: Action,
608}
609
610impl<'a> RuleDestinationBuilder<'a> {
611 pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
615 self.rule_builder
616 .commit_rule(self.action, PendingDestination::Ip(ip.into()));
617 self.rule_builder
618 }
619
620 pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
622 self.rule_builder
623 .commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
624 self.rule_builder
625 }
626
627 pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
631 self.rule_builder
632 .commit_rule(self.action, PendingDestination::Domain(domain.into()));
633 self.rule_builder
634 }
635
636 pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
639 self.rule_builder
640 .commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
641 self.rule_builder
642 }
643
644 pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
646 self.rule_builder.commit_rule(
647 self.action,
648 PendingDestination::Resolved(Destination::Group(group)),
649 );
650 self.rule_builder
651 }
652
653 pub fn any(self) -> &'a mut RuleBuilder {
655 self.rule_builder
656 .commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
657 self.rule_builder
658 }
659}
660
661#[derive(Debug, Clone)]
666struct PendingRule {
667 direction: Option<Direction>,
668 destination: PendingDestination,
669 protocols: Vec<Protocol>,
670 ports: Vec<PortRange>,
671 action: Action,
672}
673
674#[derive(Debug, Clone)]
675enum PendingDestination {
676 Resolved(Destination),
678 Ip(String),
679 Cidr(String),
680 Domain(String),
681 DomainSuffix(String),
682}
683
684impl PendingDestination {
685 fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
686 match self {
687 PendingDestination::Resolved(d) => Ok(d.clone()),
688 PendingDestination::Ip(raw) => {
689 let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
690 rule_index: idx,
691 raw: raw.clone(),
692 })?;
693 let prefix = if ip.is_ipv4() { 32 } else { 128 };
696 let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
697 rule_index: idx,
698 raw: raw.clone(),
699 })?;
700 Ok(Destination::Cidr(net))
701 }
702 PendingDestination::Cidr(raw) => {
703 let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
704 rule_index: idx,
705 raw: raw.clone(),
706 })?;
707 Ok(Destination::Cidr(net))
708 }
709 PendingDestination::Domain(raw) => {
710 let name =
711 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
712 rule_index: idx,
713 raw: raw.clone(),
714 source,
715 })?;
716 Ok(Destination::Domain(name))
717 }
718 PendingDestination::DomainSuffix(raw) => {
719 let name =
720 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
721 rule_index: idx,
722 raw: raw.clone(),
723 source,
724 })?;
725 let name = name
726 .try_into_suffix()
727 .map_err(|source| BuildError::InvalidDomain {
728 rule_index: idx,
729 raw: raw.clone(),
730 source,
731 })?;
732 Ok(Destination::DomainSuffix(name))
733 }
734 }
735 }
736}
737
738fn warn_about_shadows(rules: &[Rule]) {
750 for (i, later) in rules.iter().enumerate() {
751 for (j, earlier) in rules.iter().take(i).enumerate() {
752 if shadows(earlier, later) {
753 tracing::warn!(
754 shadowed_index = i,
755 shadowed_by = j,
756 "rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
757 later.direction,
758 later.action,
759 later.destination,
760 earlier.direction,
761 earlier.action,
762 earlier.destination,
763 );
764 }
765 }
766 }
767}
768
769fn shadows(earlier: &Rule, later: &Rule) -> bool {
772 direction_covers(earlier.direction, later.direction)
773 && destination_covers(&earlier.destination, &later.destination)
774 && protocol_set_covers(&earlier.protocols, &later.protocols)
775 && port_set_covers(&earlier.ports, &later.ports)
776}
777
778fn direction_covers(earlier: Direction, later: Direction) -> bool {
779 matches!(
780 (earlier, later),
781 (Direction::Any, _)
782 | (Direction::Egress, Direction::Egress)
783 | (Direction::Ingress, Direction::Ingress)
784 )
785}
786
787fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
788 match (earlier, later) {
789 (Destination::Any, _) => true,
790 (Destination::Group(eg), Destination::Group(lg)) => eg == lg,
791 (Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
792 _ => false,
794 }
795}
796
797fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
798 match (outer, inner) {
799 (IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
800 (IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
801 _ => false,
802 }
803}
804
805fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
806 if earlier.is_empty() {
807 return true; }
809 if later.is_empty() {
810 return false; }
812 later.iter().all(|p| earlier.contains(p))
813}
814
815fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
816 if earlier.is_empty() {
817 return true;
818 }
819 if later.is_empty() {
820 return false;
821 }
822 later.iter().all(|lp| {
823 earlier
824 .iter()
825 .any(|ep| ep.start <= lp.start && lp.end <= ep.end)
826 })
827}
828
829impl NetworkPolicy {
834 pub fn builder() -> NetworkPolicyBuilder {
836 NetworkPolicyBuilder::new()
837 }
838}
839
840#[cfg(test)]
845mod tests {
846 use super::*;
847
848 #[test]
851 fn empty_builder_yields_asymmetric_default() {
852 let p = NetworkPolicy::builder().build().unwrap();
853 assert!(matches!(p.default_egress, Action::Deny));
854 assert!(matches!(p.default_ingress, Action::Allow));
855 assert!(p.rules.is_empty());
856 }
857
858 #[test]
861 fn defaults_set_and_override() {
862 let p = NetworkPolicy::builder()
863 .default_deny()
864 .default_ingress(Action::Allow)
865 .build()
866 .unwrap();
867 assert!(matches!(p.default_egress, Action::Deny));
868 assert!(matches!(p.default_ingress, Action::Allow));
869 }
870
871 #[test]
874 fn egress_closure_commits_one_rule_per_shortcut() {
875 let p = NetworkPolicy::builder()
876 .egress(|e| e.tcp().port(443).allow_public().allow_private())
877 .build()
878 .unwrap();
879 assert_eq!(p.rules.len(), 2);
880 assert!(matches!(p.rules[0].direction, Direction::Egress));
881 assert!(matches!(p.rules[0].action, Action::Allow));
882 assert!(matches!(
883 p.rules[0].destination,
884 Destination::Group(DestinationGroup::Public)
885 ));
886 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
887 assert_eq!(p.rules[0].ports.len(), 1);
888 assert!(matches!(
889 p.rules[1].destination,
890 Destination::Group(DestinationGroup::Private)
891 ));
892 }
893
894 #[test]
896 fn allow_local_expands_to_three_groups() {
897 let p = NetworkPolicy::builder()
898 .egress(|e| e.allow_local())
899 .build()
900 .unwrap();
901 assert_eq!(p.rules.len(), 3);
902 let groups: Vec<_> = p
903 .rules
904 .iter()
905 .map(|r| match &r.destination {
906 Destination::Group(g) => *g,
907 other => panic!("unexpected destination {other:?}"),
908 })
909 .collect();
910 assert_eq!(
911 groups,
912 vec![
913 DestinationGroup::Loopback,
914 DestinationGroup::LinkLocal,
915 DestinationGroup::Host,
916 ]
917 );
918 }
919
920 #[test]
923 fn explicit_ip_parses_at_build() {
924 let p = NetworkPolicy::builder()
925 .any(|a| a.deny().ip("198.51.100.5"))
926 .build()
927 .unwrap();
928 assert_eq!(p.rules.len(), 1);
929 assert!(matches!(p.rules[0].direction, Direction::Any));
930 assert!(matches!(p.rules[0].action, Action::Deny));
931 match &p.rules[0].destination {
932 Destination::Cidr(net) => {
933 assert_eq!(net.to_string(), "198.51.100.5/32");
934 }
935 other => panic!("expected Cidr, got {other:?}"),
936 }
937 }
938
939 #[test]
942 fn invalid_ip_surfaces_at_build() {
943 let result = NetworkPolicy::builder()
944 .egress(|e| e.allow().ip("not-an-ip"))
945 .build();
946 match result {
947 Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
948 assert_eq!(raw, "not-an-ip");
949 }
950 other => panic!("expected InvalidIp, got {other:?}"),
951 }
952 }
953
954 #[test]
956 fn domain_parses_to_canonical_form() {
957 let p = NetworkPolicy::builder()
958 .egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
959 .build()
960 .unwrap();
961 match &p.rules[0].destination {
962 Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
963 other => panic!("expected Domain, got {other:?}"),
964 }
965 }
966
967 #[test]
969 fn invalid_port_range_surfaces_at_build() {
970 let result = NetworkPolicy::builder()
971 .egress(|e| e.tcp().port_range(443, 80).allow_public())
972 .build();
973 match result {
974 Err(BuildError::InvalidPortRange {
975 lo: 443, hi: 80, ..
976 }) => {}
977 other => panic!("expected InvalidPortRange, got {other:?}"),
978 }
979 }
980
981 #[test]
983 fn missing_direction_surfaces_at_build() {
984 let result = NetworkPolicy::builder()
985 .rule(|r| r.tcp().port(443).allow_public())
986 .build();
987 match result {
988 Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
989 other => panic!("expected DirectionNotSet, got {other:?}"),
990 }
991 }
992
993 #[test]
995 fn icmp_in_ingress_rejected_at_build() {
996 let result = NetworkPolicy::builder()
997 .ingress(|i| i.icmpv4().allow_public())
998 .build();
999 match result {
1000 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1001 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1002 }
1003 }
1004
1005 #[test]
1007 fn icmp_in_any_direction_rejected_at_build() {
1008 let result = NetworkPolicy::builder()
1009 .any(|a| a.icmpv6().allow_public())
1010 .build();
1011 match result {
1012 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1013 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1014 }
1015 }
1016
1017 #[test]
1019 fn duplicate_protocols_dedupe() {
1020 let p = NetworkPolicy::builder()
1021 .egress(|e| e.tcp().tcp().udp().tcp().allow_public())
1022 .build()
1023 .unwrap();
1024 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
1025 }
1026
1027 #[test]
1030 fn explicit_group_uses_typed_argument() {
1031 let p = NetworkPolicy::builder()
1032 .egress(|e| e.allow().group(DestinationGroup::Multicast))
1033 .build()
1034 .unwrap();
1035 assert!(matches!(
1036 p.rules[0].destination,
1037 Destination::Group(DestinationGroup::Multicast)
1038 ));
1039 }
1040
1041 #[test]
1045 fn chain_form_compiles_without_explicit_return() {
1046 let _ = NetworkPolicy::builder()
1047 .rule(|r| r.egress().tcp().allow_public())
1048 .build()
1049 .unwrap();
1050 }
1051
1052 #[test]
1057 fn shadowed_rule_builds_and_is_detected() {
1058 let broader = Rule {
1059 direction: Direction::Egress,
1060 destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
1061 protocols: vec![],
1062 ports: vec![],
1063 action: Action::Allow,
1064 };
1065 let narrower = Rule {
1066 direction: Direction::Egress,
1067 destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
1068 protocols: vec![],
1069 ports: vec![],
1070 action: Action::Allow,
1071 };
1072 assert!(
1073 shadows(&broader, &narrower),
1074 "10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
1075 );
1076 assert!(
1077 !shadows(&narrower, &broader),
1078 "10.0.0.5/32 should NOT shadow 10.0.0.0/8"
1079 );
1080
1081 let _ = NetworkPolicy::builder()
1084 .egress(|e| e.allow().cidr("10.0.0.0/8"))
1085 .egress(|e| e.allow().cidr("10.0.0.5/32"))
1086 .build()
1087 .unwrap();
1088 }
1089
1090 #[test]
1093 fn direction_cover_relations() {
1094 use Direction::*;
1095 assert!(direction_covers(Any, Egress));
1096 assert!(direction_covers(Any, Ingress));
1097 assert!(direction_covers(Any, Any));
1098 assert!(direction_covers(Egress, Egress));
1099 assert!(!direction_covers(Egress, Ingress));
1100 assert!(!direction_covers(Egress, Any)); assert!(direction_covers(Ingress, Ingress));
1102 assert!(!direction_covers(Ingress, Egress));
1103 assert!(!direction_covers(Ingress, Any));
1104 }
1105
1106 #[test]
1113 fn deny_domains_produces_one_rule_per_name() {
1114 let p = NetworkPolicy::builder()
1115 .default_allow()
1116 .egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
1117 .build()
1118 .unwrap();
1119 assert_eq!(p.rules.len(), 2);
1120 for rule in &p.rules {
1121 assert_eq!(rule.action, Action::Deny);
1122 assert_eq!(rule.direction, Direction::Egress);
1123 assert!(rule.protocols.is_empty(), "no protocol filter");
1124 assert!(rule.ports.is_empty(), "no port filter");
1125 }
1126 assert!(matches!(
1127 &p.rules[0].destination,
1128 Destination::Domain(d) if d.as_str() == "evil.com",
1129 ));
1130 assert!(matches!(
1131 &p.rules[1].destination,
1132 Destination::Domain(d) if d.as_str() == "tracker.example",
1133 ));
1134 }
1135
1136 #[test]
1139 fn deny_domain_suffixes_produces_one_rule_per_suffix() {
1140 let p = NetworkPolicy::builder()
1141 .default_allow()
1142 .egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
1143 .build()
1144 .unwrap();
1145 assert_eq!(p.rules.len(), 2);
1146 assert!(matches!(
1147 &p.rules[0].destination,
1148 Destination::DomainSuffix(d) if d.as_str() == "ads.example",
1149 ));
1150 assert!(matches!(
1151 &p.rules[1].destination,
1152 Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
1153 ));
1154 }
1155
1156 #[test]
1159 fn deny_domains_inherits_protocol_and_port_filter() {
1160 let p = NetworkPolicy::builder()
1161 .default_allow()
1162 .egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
1163 .build()
1164 .unwrap();
1165 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
1166 assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
1167 }
1168
1169 #[test]
1172 fn allow_domains_produces_allow_rules() {
1173 let p = NetworkPolicy::builder()
1174 .default_deny()
1175 .egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
1176 .build()
1177 .unwrap();
1178 assert_eq!(p.rules.len(), 2);
1179 for rule in &p.rules {
1180 assert_eq!(rule.action, Action::Allow);
1181 }
1182 }
1183
1184 #[test]
1186 fn deny_domains_empty_input_is_noop() {
1187 let p = NetworkPolicy::builder()
1188 .default_allow()
1189 .egress(|e| e.deny_domains(Vec::<&str>::new()))
1190 .build()
1191 .unwrap();
1192 assert!(p.rules.is_empty());
1193 }
1194
1195 #[test]
1199 fn deny_domains_invalid_input_surfaces_at_build() {
1200 let result = NetworkPolicy::builder()
1201 .default_allow()
1202 .egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
1203 .build();
1204 match result {
1205 Err(BuildError::InvalidDomain {
1206 raw, rule_index, ..
1207 }) => {
1208 assert_eq!(raw, "not a domain!");
1209 assert_eq!(rule_index, 1);
1212 }
1213 other => panic!("expected InvalidDomain, got {other:?}"),
1214 }
1215 }
1216}