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("rule #{rule_index}: invalid domain `{raw}`: {source}")]
81 InvalidDomain {
82 rule_index: usize,
83 raw: String,
84 #[source]
85 source: DomainNameError,
86 },
87
88 #[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
90 InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
91
92 #[error(
96 "rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
97 )]
98 IngressDoesNotSupportIcmp { rule_index: usize },
99}
100
101#[derive(Debug, Default)]
109pub struct NetworkPolicyBuilder {
110 default_egress: Option<Action>,
111 default_ingress: Option<Action>,
112 pending_rules: Vec<PendingRule>,
113 errors: Vec<BuildError>,
114}
115
116impl NetworkPolicyBuilder {
117 pub fn new() -> Self {
119 Self::default()
120 }
121
122 pub fn default_allow(mut self) -> Self {
124 self.default_egress = Some(Action::Allow);
125 self.default_ingress = Some(Action::Allow);
126 self
127 }
128
129 pub fn default_deny(mut self) -> Self {
131 self.default_egress = Some(Action::Deny);
132 self.default_ingress = Some(Action::Deny);
133 self
134 }
135
136 pub fn default_egress(mut self, action: Action) -> Self {
138 self.default_egress = Some(action);
139 self
140 }
141
142 pub fn default_ingress(mut self, action: Action) -> Self {
144 self.default_ingress = Some(action);
145 self
146 }
147
148 pub fn rule<F>(self, f: F) -> Self
151 where
152 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
153 {
154 self.with_rule_builder(None, f)
155 }
156
157 pub fn egress<F>(self, f: F) -> Self
159 where
160 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
161 {
162 self.with_rule_builder(Some(Direction::Egress), f)
163 }
164
165 pub fn ingress<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::Ingress), f)
171 }
172
173 pub fn any<F>(self, f: F) -> Self
176 where
177 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
178 {
179 self.with_rule_builder(Some(Direction::Any), f)
180 }
181
182 fn with_rule_builder<F>(mut self, initial_direction: Option<Direction>, f: F) -> Self
183 where
184 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
185 {
186 let mut rb = RuleBuilder {
187 direction: initial_direction,
188 protocols: Vec::new(),
189 ports: Vec::new(),
190 pending_rules: Vec::new(),
191 errors: Vec::new(),
192 };
193 let _ = f(&mut rb);
194 self.pending_rules.append(&mut rb.pending_rules);
195 self.errors.append(&mut rb.errors);
196 self
197 }
198
199 pub fn build(self) -> Result<NetworkPolicy, BuildError> {
208 if let Some(err) = self.errors.into_iter().next() {
209 return Err(err);
210 }
211
212 let mut rules = Vec::with_capacity(self.pending_rules.len());
213 for (idx, pending) in self.pending_rules.into_iter().enumerate() {
214 let direction = pending
215 .direction
216 .ok_or(BuildError::DirectionNotSet { rule_index: idx })?;
217 let destination = pending.destination.parse(idx)?;
218
219 if matches!(direction, Direction::Ingress | Direction::Any)
220 && pending
221 .protocols
222 .iter()
223 .any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
224 {
225 return Err(BuildError::IngressDoesNotSupportIcmp { rule_index: idx });
226 }
227
228 rules.push(Rule {
229 direction,
230 destination,
231 protocols: pending.protocols,
232 ports: pending.ports,
233 action: pending.action,
234 });
235 }
236
237 warn_about_shadows(&rules);
238
239 Ok(NetworkPolicy {
240 default_egress: self.default_egress.unwrap_or_else(default_egress_default),
241 default_ingress: self.default_ingress.unwrap_or_else(default_ingress_default),
242 rules,
243 })
244 }
245}
246
247fn default_egress_default() -> Action {
251 Action::Deny
252}
253
254fn default_ingress_default() -> Action {
258 Action::Allow
259}
260
261#[derive(Debug)]
271pub struct RuleBuilder {
272 direction: Option<Direction>,
273 protocols: Vec<Protocol>,
274 ports: Vec<PortRange>,
275 pending_rules: Vec<PendingRule>,
276 errors: Vec<BuildError>,
277}
278
279impl RuleBuilder {
280 pub fn egress(&mut self) -> &mut Self {
284 self.direction = Some(Direction::Egress);
285 self
286 }
287
288 pub fn ingress(&mut self) -> &mut Self {
290 self.direction = Some(Direction::Ingress);
291 self
292 }
293
294 pub fn any(&mut self) -> &mut Self {
297 self.direction = Some(Direction::Any);
298 self
299 }
300
301 pub fn tcp(&mut self) -> &mut Self {
305 self.add_protocol(Protocol::Tcp)
306 }
307
308 pub fn udp(&mut self) -> &mut Self {
310 self.add_protocol(Protocol::Udp)
311 }
312
313 pub fn icmpv4(&mut self) -> &mut Self {
317 self.add_protocol(Protocol::Icmpv4)
318 }
319
320 pub fn icmpv6(&mut self) -> &mut Self {
322 self.add_protocol(Protocol::Icmpv6)
323 }
324
325 fn add_protocol(&mut self, p: Protocol) -> &mut Self {
326 if !self.protocols.contains(&p) {
327 self.protocols.push(p);
328 }
329 self
330 }
331
332 pub fn port(&mut self, port: u16) -> &mut Self {
336 let pr = PortRange::single(port);
337 if !self.ports.contains(&pr) {
338 self.ports.push(pr);
339 }
340 self
341 }
342
343 pub fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self {
346 if lo > hi {
347 self.errors.push(BuildError::InvalidPortRange {
348 rule_index: self.pending_rules.len(),
349 lo,
350 hi,
351 });
352 return self;
353 }
354 let pr = PortRange::range(lo, hi);
355 if !self.ports.contains(&pr) {
356 self.ports.push(pr);
357 }
358 self
359 }
360
361 pub fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self {
364 for p in ports {
365 self.port(p);
366 }
367 self
368 }
369
370 pub fn allow_public(&mut self) -> &mut Self {
374 self.commit_group(Action::Allow, DestinationGroup::Public)
375 }
376
377 pub fn deny_public(&mut self) -> &mut Self {
379 self.commit_group(Action::Deny, DestinationGroup::Public)
380 }
381
382 pub fn allow_private(&mut self) -> &mut Self {
384 self.commit_group(Action::Allow, DestinationGroup::Private)
385 }
386
387 pub fn deny_private(&mut self) -> &mut Self {
389 self.commit_group(Action::Deny, DestinationGroup::Private)
390 }
391
392 pub fn allow_loopback(&mut self) -> &mut Self {
401 self.commit_group(Action::Allow, DestinationGroup::Loopback)
402 }
403
404 pub fn deny_loopback(&mut self) -> &mut Self {
412 self.commit_group(Action::Deny, DestinationGroup::Loopback)
413 }
414
415 pub fn allow_link_local(&mut self) -> &mut Self {
419 self.commit_group(Action::Allow, DestinationGroup::LinkLocal)
420 }
421
422 pub fn deny_link_local(&mut self) -> &mut Self {
424 self.commit_group(Action::Deny, DestinationGroup::LinkLocal)
425 }
426
427 pub fn allow_meta(&mut self) -> &mut Self {
430 self.commit_group(Action::Allow, DestinationGroup::Metadata)
431 }
432
433 pub fn deny_meta(&mut self) -> &mut Self {
435 self.commit_group(Action::Deny, DestinationGroup::Metadata)
436 }
437
438 pub fn allow_multicast(&mut self) -> &mut Self {
440 self.commit_group(Action::Allow, DestinationGroup::Multicast)
441 }
442
443 pub fn deny_multicast(&mut self) -> &mut Self {
445 self.commit_group(Action::Deny, DestinationGroup::Multicast)
446 }
447
448 pub fn allow_host(&mut self) -> &mut Self {
453 self.commit_group(Action::Allow, DestinationGroup::Host)
454 }
455
456 pub fn deny_host(&mut self) -> &mut Self {
458 self.commit_group(Action::Deny, DestinationGroup::Host)
459 }
460
461 pub fn allow_local(&mut self) -> &mut Self {
474 self.allow_loopback();
475 self.allow_link_local();
476 self.allow_host();
477 self
478 }
479
480 pub fn deny_local(&mut self) -> &mut Self {
483 self.deny_loopback();
484 self.deny_link_local();
485 self.deny_host();
486 self
487 }
488
489 pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
493 where
494 I: IntoIterator<Item = S>,
495 S: Into<String>,
496 {
497 for name in names {
498 self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
499 }
500 self
501 }
502
503 pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
505 where
506 I: IntoIterator<Item = S>,
507 S: Into<String>,
508 {
509 for name in names {
510 self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
511 }
512 self
513 }
514
515 pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
517 where
518 I: IntoIterator<Item = S>,
519 S: Into<String>,
520 {
521 for suffix in suffixes {
522 self.commit_rule(
523 Action::Allow,
524 PendingDestination::DomainSuffix(suffix.into()),
525 );
526 }
527 self
528 }
529
530 pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
532 where
533 I: IntoIterator<Item = S>,
534 S: Into<String>,
535 {
536 for suffix in suffixes {
537 self.commit_rule(
538 Action::Deny,
539 PendingDestination::DomainSuffix(suffix.into()),
540 );
541 }
542 self
543 }
544
545 pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
552 RuleDestinationBuilder {
553 rule_builder: self,
554 action: Action::Allow,
555 }
556 }
557
558 pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
560 RuleDestinationBuilder {
561 rule_builder: self,
562 action: Action::Deny,
563 }
564 }
565
566 fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
569 self.commit_rule(
570 action,
571 PendingDestination::Resolved(Destination::Group(group)),
572 );
573 self
574 }
575
576 fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
577 self.pending_rules.push(PendingRule {
578 direction: self.direction,
579 destination,
580 protocols: self.protocols.clone(),
581 ports: self.ports.clone(),
582 action,
583 });
584 }
585}
586
587#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
597pub struct RuleDestinationBuilder<'a> {
598 rule_builder: &'a mut RuleBuilder,
599 action: Action,
600}
601
602impl<'a> RuleDestinationBuilder<'a> {
603 pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
607 self.rule_builder
608 .commit_rule(self.action, PendingDestination::Ip(ip.into()));
609 self.rule_builder
610 }
611
612 pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
614 self.rule_builder
615 .commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
616 self.rule_builder
617 }
618
619 pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
623 self.rule_builder
624 .commit_rule(self.action, PendingDestination::Domain(domain.into()));
625 self.rule_builder
626 }
627
628 pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
631 self.rule_builder
632 .commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
633 self.rule_builder
634 }
635
636 pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
638 self.rule_builder.commit_rule(
639 self.action,
640 PendingDestination::Resolved(Destination::Group(group)),
641 );
642 self.rule_builder
643 }
644
645 pub fn any(self) -> &'a mut RuleBuilder {
647 self.rule_builder
648 .commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
649 self.rule_builder
650 }
651}
652
653#[derive(Debug, Clone)]
658struct PendingRule {
659 direction: Option<Direction>,
660 destination: PendingDestination,
661 protocols: Vec<Protocol>,
662 ports: Vec<PortRange>,
663 action: Action,
664}
665
666#[derive(Debug, Clone)]
667enum PendingDestination {
668 Resolved(Destination),
670 Ip(String),
671 Cidr(String),
672 Domain(String),
673 DomainSuffix(String),
674}
675
676impl PendingDestination {
677 fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
678 match self {
679 PendingDestination::Resolved(d) => Ok(d.clone()),
680 PendingDestination::Ip(raw) => {
681 let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
682 rule_index: idx,
683 raw: raw.clone(),
684 })?;
685 let prefix = if ip.is_ipv4() { 32 } else { 128 };
688 let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
689 rule_index: idx,
690 raw: raw.clone(),
691 })?;
692 Ok(Destination::Cidr(net))
693 }
694 PendingDestination::Cidr(raw) => {
695 let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
696 rule_index: idx,
697 raw: raw.clone(),
698 })?;
699 Ok(Destination::Cidr(net))
700 }
701 PendingDestination::Domain(raw) => {
702 let name =
703 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
704 rule_index: idx,
705 raw: raw.clone(),
706 source,
707 })?;
708 Ok(Destination::Domain(name))
709 }
710 PendingDestination::DomainSuffix(raw) => {
711 let name =
712 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
713 rule_index: idx,
714 raw: raw.clone(),
715 source,
716 })?;
717 Ok(Destination::DomainSuffix(name))
718 }
719 }
720 }
721}
722
723fn warn_about_shadows(rules: &[Rule]) {
735 for (i, later) in rules.iter().enumerate() {
736 for (j, earlier) in rules.iter().take(i).enumerate() {
737 if shadows(earlier, later) {
738 tracing::warn!(
739 shadowed_index = i,
740 shadowed_by = j,
741 "rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
742 later.direction,
743 later.action,
744 later.destination,
745 earlier.direction,
746 earlier.action,
747 earlier.destination,
748 );
749 }
750 }
751 }
752}
753
754fn shadows(earlier: &Rule, later: &Rule) -> bool {
757 direction_covers(earlier.direction, later.direction)
758 && destination_covers(&earlier.destination, &later.destination)
759 && protocol_set_covers(&earlier.protocols, &later.protocols)
760 && port_set_covers(&earlier.ports, &later.ports)
761}
762
763fn direction_covers(earlier: Direction, later: Direction) -> bool {
764 matches!(
765 (earlier, later),
766 (Direction::Any, _)
767 | (Direction::Egress, Direction::Egress)
768 | (Direction::Ingress, Direction::Ingress)
769 )
770}
771
772fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
773 match (earlier, later) {
774 (Destination::Any, _) => true,
775 (Destination::Group(eg), Destination::Group(lg)) => eg == lg,
776 (Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
777 _ => false,
779 }
780}
781
782fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
783 match (outer, inner) {
784 (IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
785 (IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
786 _ => false,
787 }
788}
789
790fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
791 if earlier.is_empty() {
792 return true; }
794 if later.is_empty() {
795 return false; }
797 later.iter().all(|p| earlier.contains(p))
798}
799
800fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
801 if earlier.is_empty() {
802 return true;
803 }
804 if later.is_empty() {
805 return false;
806 }
807 later.iter().all(|lp| {
808 earlier
809 .iter()
810 .any(|ep| ep.start <= lp.start && lp.end <= ep.end)
811 })
812}
813
814impl NetworkPolicy {
819 pub fn builder() -> NetworkPolicyBuilder {
821 NetworkPolicyBuilder::new()
822 }
823}
824
825#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
836 fn empty_builder_yields_asymmetric_default() {
837 let p = NetworkPolicy::builder().build().unwrap();
838 assert!(matches!(p.default_egress, Action::Deny));
839 assert!(matches!(p.default_ingress, Action::Allow));
840 assert!(p.rules.is_empty());
841 }
842
843 #[test]
846 fn defaults_set_and_override() {
847 let p = NetworkPolicy::builder()
848 .default_deny()
849 .default_ingress(Action::Allow)
850 .build()
851 .unwrap();
852 assert!(matches!(p.default_egress, Action::Deny));
853 assert!(matches!(p.default_ingress, Action::Allow));
854 }
855
856 #[test]
859 fn egress_closure_commits_one_rule_per_shortcut() {
860 let p = NetworkPolicy::builder()
861 .egress(|e| e.tcp().port(443).allow_public().allow_private())
862 .build()
863 .unwrap();
864 assert_eq!(p.rules.len(), 2);
865 assert!(matches!(p.rules[0].direction, Direction::Egress));
866 assert!(matches!(p.rules[0].action, Action::Allow));
867 assert!(matches!(
868 p.rules[0].destination,
869 Destination::Group(DestinationGroup::Public)
870 ));
871 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
872 assert_eq!(p.rules[0].ports.len(), 1);
873 assert!(matches!(
874 p.rules[1].destination,
875 Destination::Group(DestinationGroup::Private)
876 ));
877 }
878
879 #[test]
881 fn allow_local_expands_to_three_groups() {
882 let p = NetworkPolicy::builder()
883 .egress(|e| e.allow_local())
884 .build()
885 .unwrap();
886 assert_eq!(p.rules.len(), 3);
887 let groups: Vec<_> = p
888 .rules
889 .iter()
890 .map(|r| match &r.destination {
891 Destination::Group(g) => *g,
892 other => panic!("unexpected destination {other:?}"),
893 })
894 .collect();
895 assert_eq!(
896 groups,
897 vec![
898 DestinationGroup::Loopback,
899 DestinationGroup::LinkLocal,
900 DestinationGroup::Host,
901 ]
902 );
903 }
904
905 #[test]
908 fn explicit_ip_parses_at_build() {
909 let p = NetworkPolicy::builder()
910 .any(|a| a.deny().ip("198.51.100.5"))
911 .build()
912 .unwrap();
913 assert_eq!(p.rules.len(), 1);
914 assert!(matches!(p.rules[0].direction, Direction::Any));
915 assert!(matches!(p.rules[0].action, Action::Deny));
916 match &p.rules[0].destination {
917 Destination::Cidr(net) => {
918 assert_eq!(net.to_string(), "198.51.100.5/32");
919 }
920 other => panic!("expected Cidr, got {other:?}"),
921 }
922 }
923
924 #[test]
927 fn invalid_ip_surfaces_at_build() {
928 let result = NetworkPolicy::builder()
929 .egress(|e| e.allow().ip("not-an-ip"))
930 .build();
931 match result {
932 Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
933 assert_eq!(raw, "not-an-ip");
934 }
935 other => panic!("expected InvalidIp, got {other:?}"),
936 }
937 }
938
939 #[test]
941 fn domain_parses_to_canonical_form() {
942 let p = NetworkPolicy::builder()
943 .egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
944 .build()
945 .unwrap();
946 match &p.rules[0].destination {
947 Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
948 other => panic!("expected Domain, got {other:?}"),
949 }
950 }
951
952 #[test]
954 fn invalid_port_range_surfaces_at_build() {
955 let result = NetworkPolicy::builder()
956 .egress(|e| e.tcp().port_range(443, 80).allow_public())
957 .build();
958 match result {
959 Err(BuildError::InvalidPortRange {
960 lo: 443, hi: 80, ..
961 }) => {}
962 other => panic!("expected InvalidPortRange, got {other:?}"),
963 }
964 }
965
966 #[test]
968 fn missing_direction_surfaces_at_build() {
969 let result = NetworkPolicy::builder()
970 .rule(|r| r.tcp().port(443).allow_public())
971 .build();
972 match result {
973 Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
974 other => panic!("expected DirectionNotSet, got {other:?}"),
975 }
976 }
977
978 #[test]
980 fn icmp_in_ingress_rejected_at_build() {
981 let result = NetworkPolicy::builder()
982 .ingress(|i| i.icmpv4().allow_public())
983 .build();
984 match result {
985 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
986 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
987 }
988 }
989
990 #[test]
992 fn icmp_in_any_direction_rejected_at_build() {
993 let result = NetworkPolicy::builder()
994 .any(|a| a.icmpv6().allow_public())
995 .build();
996 match result {
997 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
998 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
999 }
1000 }
1001
1002 #[test]
1004 fn duplicate_protocols_dedupe() {
1005 let p = NetworkPolicy::builder()
1006 .egress(|e| e.tcp().tcp().udp().tcp().allow_public())
1007 .build()
1008 .unwrap();
1009 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
1010 }
1011
1012 #[test]
1015 fn explicit_group_uses_typed_argument() {
1016 let p = NetworkPolicy::builder()
1017 .egress(|e| e.allow().group(DestinationGroup::Multicast))
1018 .build()
1019 .unwrap();
1020 assert!(matches!(
1021 p.rules[0].destination,
1022 Destination::Group(DestinationGroup::Multicast)
1023 ));
1024 }
1025
1026 #[test]
1030 fn chain_form_compiles_without_explicit_return() {
1031 let _ = NetworkPolicy::builder()
1032 .rule(|r| r.egress().tcp().allow_public())
1033 .build()
1034 .unwrap();
1035 }
1036
1037 #[test]
1042 fn shadowed_rule_builds_and_is_detected() {
1043 let broader = Rule {
1044 direction: Direction::Egress,
1045 destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
1046 protocols: vec![],
1047 ports: vec![],
1048 action: Action::Allow,
1049 };
1050 let narrower = Rule {
1051 direction: Direction::Egress,
1052 destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
1053 protocols: vec![],
1054 ports: vec![],
1055 action: Action::Allow,
1056 };
1057 assert!(
1058 shadows(&broader, &narrower),
1059 "10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
1060 );
1061 assert!(
1062 !shadows(&narrower, &broader),
1063 "10.0.0.5/32 should NOT shadow 10.0.0.0/8"
1064 );
1065
1066 let _ = NetworkPolicy::builder()
1069 .egress(|e| e.allow().cidr("10.0.0.0/8"))
1070 .egress(|e| e.allow().cidr("10.0.0.5/32"))
1071 .build()
1072 .unwrap();
1073 }
1074
1075 #[test]
1078 fn direction_cover_relations() {
1079 use Direction::*;
1080 assert!(direction_covers(Any, Egress));
1081 assert!(direction_covers(Any, Ingress));
1082 assert!(direction_covers(Any, Any));
1083 assert!(direction_covers(Egress, Egress));
1084 assert!(!direction_covers(Egress, Ingress));
1085 assert!(!direction_covers(Egress, Any)); assert!(direction_covers(Ingress, Ingress));
1087 assert!(!direction_covers(Ingress, Egress));
1088 assert!(!direction_covers(Ingress, Any));
1089 }
1090
1091 #[test]
1098 fn deny_domains_produces_one_rule_per_name() {
1099 let p = NetworkPolicy::builder()
1100 .default_allow()
1101 .egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
1102 .build()
1103 .unwrap();
1104 assert_eq!(p.rules.len(), 2);
1105 for rule in &p.rules {
1106 assert_eq!(rule.action, Action::Deny);
1107 assert_eq!(rule.direction, Direction::Egress);
1108 assert!(rule.protocols.is_empty(), "no protocol filter");
1109 assert!(rule.ports.is_empty(), "no port filter");
1110 }
1111 assert!(matches!(
1112 &p.rules[0].destination,
1113 Destination::Domain(d) if d.as_str() == "evil.com",
1114 ));
1115 assert!(matches!(
1116 &p.rules[1].destination,
1117 Destination::Domain(d) if d.as_str() == "tracker.example",
1118 ));
1119 }
1120
1121 #[test]
1124 fn deny_domain_suffixes_produces_one_rule_per_suffix() {
1125 let p = NetworkPolicy::builder()
1126 .default_allow()
1127 .egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
1128 .build()
1129 .unwrap();
1130 assert_eq!(p.rules.len(), 2);
1131 assert!(matches!(
1132 &p.rules[0].destination,
1133 Destination::DomainSuffix(d) if d.as_str() == "ads.example",
1134 ));
1135 assert!(matches!(
1136 &p.rules[1].destination,
1137 Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
1138 ));
1139 }
1140
1141 #[test]
1144 fn deny_domains_inherits_protocol_and_port_filter() {
1145 let p = NetworkPolicy::builder()
1146 .default_allow()
1147 .egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
1148 .build()
1149 .unwrap();
1150 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
1151 assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
1152 }
1153
1154 #[test]
1157 fn allow_domains_produces_allow_rules() {
1158 let p = NetworkPolicy::builder()
1159 .default_deny()
1160 .egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
1161 .build()
1162 .unwrap();
1163 assert_eq!(p.rules.len(), 2);
1164 for rule in &p.rules {
1165 assert_eq!(rule.action, Action::Allow);
1166 }
1167 }
1168
1169 #[test]
1171 fn deny_domains_empty_input_is_noop() {
1172 let p = NetworkPolicy::builder()
1173 .default_allow()
1174 .egress(|e| e.deny_domains(Vec::<&str>::new()))
1175 .build()
1176 .unwrap();
1177 assert!(p.rules.is_empty());
1178 }
1179
1180 #[test]
1184 fn deny_domains_invalid_input_surfaces_at_build() {
1185 let result = NetworkPolicy::builder()
1186 .default_allow()
1187 .egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
1188 .build();
1189 match result {
1190 Err(BuildError::InvalidDomain {
1191 raw, rule_index, ..
1192 }) => {
1193 assert_eq!(raw, "not a domain!");
1194 assert_eq!(rule_index, 1);
1197 }
1198 other => panic!("expected InvalidDomain, got {other:?}"),
1199 }
1200 }
1201}