1use std::str::FromStr;
33
34use ipnetwork::IpNetwork;
35
36use crate::secrets::config::SecretConfigError;
37
38use super::{
39 Action, Destination, DestinationGroup, Direction, DomainName, DomainNameError, NetworkPolicy,
40 PortRange, Protocol, Rule,
41};
42
43#[derive(Debug, Clone, thiserror::Error)]
57pub enum BuildError {
58 #[error(
60 "rule #{rule_index}: direction not set; call .egress(), .ingress(), or .any() before the rule-adder"
61 )]
62 DirectionNotSet { rule_index: usize },
63
64 #[error(
67 "rule #{rule_index}: destination not set; call .ip(), .cidr(), .domain(), .domain_suffix(), .group(), or .any() on the rule-destination builder"
68 )]
69 MissingDestination { rule_index: usize },
70
71 #[error("rule #{rule_index}: invalid IP address `{raw}`")]
74 InvalidIp { rule_index: usize, raw: String },
75
76 #[error("rule #{rule_index}: invalid CIDR `{raw}`")]
78 InvalidCidr { rule_index: usize, raw: String },
79
80 #[error("invalid IPv4 pool `{raw}`: prefix must be /30 or shorter")]
82 InvalidIpv4Pool { raw: String },
83
84 #[error("invalid IPv6 pool `{raw}`: prefix must be /64 or shorter")]
86 InvalidIpv6Pool { raw: String },
87
88 #[error("rule #{rule_index}: invalid domain `{raw}`: {source}")]
91 InvalidDomain {
92 rule_index: usize,
93 raw: String,
94 #[source]
95 source: DomainNameError,
96 },
97
98 #[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
100 InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
101
102 #[error(
106 "rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
107 )]
108 IngressDoesNotSupportIcmp { rule_index: usize },
109
110 #[error("{source}")]
112 InvalidSecretConfig {
113 #[from]
115 source: SecretConfigError,
116 },
117}
118
119#[derive(Debug, Default)]
127pub struct NetworkPolicyBuilder {
128 default_egress: Option<Action>,
129 default_ingress: Option<Action>,
130 pending_rules: Vec<PendingRule>,
131 errors: Vec<BuildError>,
132}
133
134impl NetworkPolicyBuilder {
135 pub fn new() -> Self {
137 Self::default()
138 }
139
140 pub fn default_allow(mut self) -> Self {
142 self.default_egress = Some(Action::Allow);
143 self.default_ingress = Some(Action::Allow);
144 self
145 }
146
147 pub fn default_deny(mut self) -> Self {
149 self.default_egress = Some(Action::Deny);
150 self.default_ingress = Some(Action::Deny);
151 self
152 }
153
154 pub fn default_egress(mut self, action: Action) -> Self {
156 self.default_egress = Some(action);
157 self
158 }
159
160 pub fn default_ingress(mut self, action: Action) -> Self {
162 self.default_ingress = Some(action);
163 self
164 }
165
166 pub fn rule<F>(self, f: F) -> Self
169 where
170 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
171 {
172 self.with_rule_builder(None, f)
173 }
174
175 pub fn egress<F>(self, f: F) -> Self
177 where
178 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
179 {
180 self.with_rule_builder(Some(Direction::Egress), f)
181 }
182
183 pub fn ingress<F>(self, f: F) -> Self
185 where
186 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
187 {
188 self.with_rule_builder(Some(Direction::Ingress), f)
189 }
190
191 pub fn any<F>(self, f: F) -> Self
194 where
195 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
196 {
197 self.with_rule_builder(Some(Direction::Any), f)
198 }
199
200 fn with_rule_builder<F>(mut self, initial_direction: Option<Direction>, f: F) -> Self
201 where
202 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
203 {
204 let mut rb = RuleBuilder {
205 direction: initial_direction,
206 protocols: Vec::new(),
207 ports: Vec::new(),
208 pending_rules: Vec::new(),
209 errors: Vec::new(),
210 };
211 let _ = f(&mut rb);
212 self.pending_rules.append(&mut rb.pending_rules);
213 self.errors.append(&mut rb.errors);
214 self
215 }
216
217 pub fn build(self) -> Result<NetworkPolicy, BuildError> {
226 if let Some(err) = self.errors.into_iter().next() {
227 return Err(err);
228 }
229
230 let mut rules = Vec::with_capacity(self.pending_rules.len());
231 for (idx, pending) in self.pending_rules.into_iter().enumerate() {
232 let direction = pending
233 .direction
234 .ok_or(BuildError::DirectionNotSet { rule_index: idx })?;
235 let destination = pending.destination.parse(idx)?;
236
237 if matches!(direction, Direction::Ingress | Direction::Any)
238 && pending
239 .protocols
240 .iter()
241 .any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
242 {
243 return Err(BuildError::IngressDoesNotSupportIcmp { rule_index: idx });
244 }
245
246 rules.push(Rule {
247 direction,
248 destination,
249 protocols: pending.protocols,
250 ports: pending.ports,
251 action: pending.action,
252 });
253 }
254
255 warn_about_shadows(&rules);
256
257 Ok(NetworkPolicy {
258 default_egress: self.default_egress.unwrap_or_else(default_egress_default),
259 default_ingress: self.default_ingress.unwrap_or_else(default_ingress_default),
260 rules,
261 })
262 }
263}
264
265fn default_egress_default() -> Action {
269 Action::Deny
270}
271
272fn default_ingress_default() -> Action {
276 Action::Allow
277}
278
279#[derive(Debug)]
289pub struct RuleBuilder {
290 direction: Option<Direction>,
291 protocols: Vec<Protocol>,
292 ports: Vec<PortRange>,
293 pending_rules: Vec<PendingRule>,
294 errors: Vec<BuildError>,
295}
296
297impl RuleBuilder {
298 pub fn egress(&mut self) -> &mut Self {
302 self.direction = Some(Direction::Egress);
303 self
304 }
305
306 pub fn ingress(&mut self) -> &mut Self {
308 self.direction = Some(Direction::Ingress);
309 self
310 }
311
312 pub fn any(&mut self) -> &mut Self {
315 self.direction = Some(Direction::Any);
316 self
317 }
318
319 pub fn tcp(&mut self) -> &mut Self {
323 self.add_protocol(Protocol::Tcp)
324 }
325
326 pub fn udp(&mut self) -> &mut Self {
328 self.add_protocol(Protocol::Udp)
329 }
330
331 pub fn icmpv4(&mut self) -> &mut Self {
335 self.add_protocol(Protocol::Icmpv4)
336 }
337
338 pub fn icmpv6(&mut self) -> &mut Self {
340 self.add_protocol(Protocol::Icmpv6)
341 }
342
343 fn add_protocol(&mut self, p: Protocol) -> &mut Self {
344 if !self.protocols.contains(&p) {
345 self.protocols.push(p);
346 }
347 self
348 }
349
350 pub fn port(&mut self, port: u16) -> &mut Self {
354 let pr = PortRange::single(port);
355 if !self.ports.contains(&pr) {
356 self.ports.push(pr);
357 }
358 self
359 }
360
361 pub fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self {
364 if lo > hi {
365 self.errors.push(BuildError::InvalidPortRange {
366 rule_index: self.pending_rules.len(),
367 lo,
368 hi,
369 });
370 return self;
371 }
372 let pr = PortRange::range(lo, hi);
373 if !self.ports.contains(&pr) {
374 self.ports.push(pr);
375 }
376 self
377 }
378
379 pub fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self {
382 for p in ports {
383 self.port(p);
384 }
385 self
386 }
387
388 pub fn allow_public(&mut self) -> &mut Self {
392 self.commit_group(Action::Allow, DestinationGroup::Public)
393 }
394
395 pub fn deny_public(&mut self) -> &mut Self {
397 self.commit_group(Action::Deny, DestinationGroup::Public)
398 }
399
400 pub fn allow_private(&mut self) -> &mut Self {
402 self.commit_group(Action::Allow, DestinationGroup::Private)
403 }
404
405 pub fn deny_private(&mut self) -> &mut Self {
407 self.commit_group(Action::Deny, DestinationGroup::Private)
408 }
409
410 pub fn allow_loopback(&mut self) -> &mut Self {
419 self.commit_group(Action::Allow, DestinationGroup::Loopback)
420 }
421
422 pub fn deny_loopback(&mut self) -> &mut Self {
430 self.commit_group(Action::Deny, DestinationGroup::Loopback)
431 }
432
433 pub fn allow_link_local(&mut self) -> &mut Self {
437 self.commit_group(Action::Allow, DestinationGroup::LinkLocal)
438 }
439
440 pub fn deny_link_local(&mut self) -> &mut Self {
442 self.commit_group(Action::Deny, DestinationGroup::LinkLocal)
443 }
444
445 pub fn allow_meta(&mut self) -> &mut Self {
448 self.commit_group(Action::Allow, DestinationGroup::Metadata)
449 }
450
451 pub fn deny_meta(&mut self) -> &mut Self {
453 self.commit_group(Action::Deny, DestinationGroup::Metadata)
454 }
455
456 pub fn allow_multicast(&mut self) -> &mut Self {
458 self.commit_group(Action::Allow, DestinationGroup::Multicast)
459 }
460
461 pub fn deny_multicast(&mut self) -> &mut Self {
463 self.commit_group(Action::Deny, DestinationGroup::Multicast)
464 }
465
466 pub fn allow_host(&mut self) -> &mut Self {
471 self.commit_group(Action::Allow, DestinationGroup::Host)
472 }
473
474 pub fn deny_host(&mut self) -> &mut Self {
476 self.commit_group(Action::Deny, DestinationGroup::Host)
477 }
478
479 pub fn allow_local(&mut self) -> &mut Self {
492 self.allow_loopback();
493 self.allow_link_local();
494 self.allow_host();
495 self
496 }
497
498 pub fn deny_local(&mut self) -> &mut Self {
501 self.deny_loopback();
502 self.deny_link_local();
503 self.deny_host();
504 self
505 }
506
507 pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
511 where
512 I: IntoIterator<Item = S>,
513 S: Into<String>,
514 {
515 for name in names {
516 self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
517 }
518 self
519 }
520
521 pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
523 where
524 I: IntoIterator<Item = S>,
525 S: Into<String>,
526 {
527 for name in names {
528 self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
529 }
530 self
531 }
532
533 pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
535 where
536 I: IntoIterator<Item = S>,
537 S: Into<String>,
538 {
539 for suffix in suffixes {
540 self.commit_rule(
541 Action::Allow,
542 PendingDestination::DomainSuffix(suffix.into()),
543 );
544 }
545 self
546 }
547
548 pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
550 where
551 I: IntoIterator<Item = S>,
552 S: Into<String>,
553 {
554 for suffix in suffixes {
555 self.commit_rule(
556 Action::Deny,
557 PendingDestination::DomainSuffix(suffix.into()),
558 );
559 }
560 self
561 }
562
563 pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
570 RuleDestinationBuilder {
571 rule_builder: self,
572 action: Action::Allow,
573 }
574 }
575
576 pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
578 RuleDestinationBuilder {
579 rule_builder: self,
580 action: Action::Deny,
581 }
582 }
583
584 fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
587 self.commit_rule(
588 action,
589 PendingDestination::Resolved(Destination::Group(group)),
590 );
591 self
592 }
593
594 fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
595 self.pending_rules.push(PendingRule {
596 direction: self.direction,
597 destination,
598 protocols: self.protocols.clone(),
599 ports: self.ports.clone(),
600 action,
601 });
602 }
603}
604
605#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
615pub struct RuleDestinationBuilder<'a> {
616 rule_builder: &'a mut RuleBuilder,
617 action: Action,
618}
619
620impl<'a> RuleDestinationBuilder<'a> {
621 pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
625 self.rule_builder
626 .commit_rule(self.action, PendingDestination::Ip(ip.into()));
627 self.rule_builder
628 }
629
630 pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
632 self.rule_builder
633 .commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
634 self.rule_builder
635 }
636
637 pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
641 self.rule_builder
642 .commit_rule(self.action, PendingDestination::Domain(domain.into()));
643 self.rule_builder
644 }
645
646 pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
649 self.rule_builder
650 .commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
651 self.rule_builder
652 }
653
654 pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
656 self.rule_builder.commit_rule(
657 self.action,
658 PendingDestination::Resolved(Destination::Group(group)),
659 );
660 self.rule_builder
661 }
662
663 pub fn any(self) -> &'a mut RuleBuilder {
665 self.rule_builder
666 .commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
667 self.rule_builder
668 }
669}
670
671#[derive(Debug, Clone)]
676struct PendingRule {
677 direction: Option<Direction>,
678 destination: PendingDestination,
679 protocols: Vec<Protocol>,
680 ports: Vec<PortRange>,
681 action: Action,
682}
683
684#[derive(Debug, Clone)]
685enum PendingDestination {
686 Resolved(Destination),
688 Ip(String),
689 Cidr(String),
690 Domain(String),
691 DomainSuffix(String),
692}
693
694impl PendingDestination {
695 fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
696 match self {
697 PendingDestination::Resolved(d) => Ok(d.clone()),
698 PendingDestination::Ip(raw) => {
699 let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
700 rule_index: idx,
701 raw: raw.clone(),
702 })?;
703 let prefix = if ip.is_ipv4() { 32 } else { 128 };
706 let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
707 rule_index: idx,
708 raw: raw.clone(),
709 })?;
710 Ok(Destination::Cidr(net))
711 }
712 PendingDestination::Cidr(raw) => {
713 let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
714 rule_index: idx,
715 raw: raw.clone(),
716 })?;
717 Ok(Destination::Cidr(net))
718 }
719 PendingDestination::Domain(raw) => {
720 let name =
721 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
722 rule_index: idx,
723 raw: raw.clone(),
724 source,
725 })?;
726 Ok(Destination::Domain(name))
727 }
728 PendingDestination::DomainSuffix(raw) => {
729 let name =
730 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
731 rule_index: idx,
732 raw: raw.clone(),
733 source,
734 })?;
735 let name = name
736 .try_into_suffix()
737 .map_err(|source| BuildError::InvalidDomain {
738 rule_index: idx,
739 raw: raw.clone(),
740 source,
741 })?;
742 Ok(Destination::DomainSuffix(name))
743 }
744 }
745 }
746}
747
748fn warn_about_shadows(rules: &[Rule]) {
760 for (i, later) in rules.iter().enumerate() {
761 for (j, earlier) in rules.iter().take(i).enumerate() {
762 if shadows(earlier, later) {
763 tracing::warn!(
764 shadowed_index = i,
765 shadowed_by = j,
766 "rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
767 later.direction,
768 later.action,
769 later.destination,
770 earlier.direction,
771 earlier.action,
772 earlier.destination,
773 );
774 }
775 }
776 }
777}
778
779fn shadows(earlier: &Rule, later: &Rule) -> bool {
782 direction_covers(earlier.direction, later.direction)
783 && destination_covers(&earlier.destination, &later.destination)
784 && protocol_set_covers(&earlier.protocols, &later.protocols)
785 && port_set_covers(&earlier.ports, &later.ports)
786}
787
788fn direction_covers(earlier: Direction, later: Direction) -> bool {
789 matches!(
790 (earlier, later),
791 (Direction::Any, _)
792 | (Direction::Egress, Direction::Egress)
793 | (Direction::Ingress, Direction::Ingress)
794 )
795}
796
797fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
798 match (earlier, later) {
799 (Destination::Any, _) => true,
800 (Destination::Group(eg), Destination::Group(lg)) => eg == lg,
801 (Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
802 _ => false,
804 }
805}
806
807fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
808 match (outer, inner) {
809 (IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
810 (IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
811 _ => false,
812 }
813}
814
815fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
816 if earlier.is_empty() {
817 return true; }
819 if later.is_empty() {
820 return false; }
822 later.iter().all(|p| earlier.contains(p))
823}
824
825fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
826 if earlier.is_empty() {
827 return true;
828 }
829 if later.is_empty() {
830 return false;
831 }
832 later.iter().all(|lp| {
833 earlier
834 .iter()
835 .any(|ep| ep.start <= lp.start && lp.end <= ep.end)
836 })
837}
838
839impl NetworkPolicy {
844 pub fn builder() -> NetworkPolicyBuilder {
846 NetworkPolicyBuilder::new()
847 }
848}
849
850#[cfg(test)]
855mod tests {
856 use super::*;
857
858 #[test]
861 fn empty_builder_yields_asymmetric_default() {
862 let p = NetworkPolicy::builder().build().unwrap();
863 assert!(matches!(p.default_egress, Action::Deny));
864 assert!(matches!(p.default_ingress, Action::Allow));
865 assert!(p.rules.is_empty());
866 }
867
868 #[test]
871 fn defaults_set_and_override() {
872 let p = NetworkPolicy::builder()
873 .default_deny()
874 .default_ingress(Action::Allow)
875 .build()
876 .unwrap();
877 assert!(matches!(p.default_egress, Action::Deny));
878 assert!(matches!(p.default_ingress, Action::Allow));
879 }
880
881 #[test]
884 fn egress_closure_commits_one_rule_per_shortcut() {
885 let p = NetworkPolicy::builder()
886 .egress(|e| e.tcp().port(443).allow_public().allow_private())
887 .build()
888 .unwrap();
889 assert_eq!(p.rules.len(), 2);
890 assert!(matches!(p.rules[0].direction, Direction::Egress));
891 assert!(matches!(p.rules[0].action, Action::Allow));
892 assert!(matches!(
893 p.rules[0].destination,
894 Destination::Group(DestinationGroup::Public)
895 ));
896 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
897 assert_eq!(p.rules[0].ports.len(), 1);
898 assert!(matches!(
899 p.rules[1].destination,
900 Destination::Group(DestinationGroup::Private)
901 ));
902 }
903
904 #[test]
906 fn allow_local_expands_to_three_groups() {
907 let p = NetworkPolicy::builder()
908 .egress(|e| e.allow_local())
909 .build()
910 .unwrap();
911 assert_eq!(p.rules.len(), 3);
912 let groups: Vec<_> = p
913 .rules
914 .iter()
915 .map(|r| match &r.destination {
916 Destination::Group(g) => *g,
917 other => panic!("unexpected destination {other:?}"),
918 })
919 .collect();
920 assert_eq!(
921 groups,
922 vec![
923 DestinationGroup::Loopback,
924 DestinationGroup::LinkLocal,
925 DestinationGroup::Host,
926 ]
927 );
928 }
929
930 #[test]
933 fn explicit_ip_parses_at_build() {
934 let p = NetworkPolicy::builder()
935 .any(|a| a.deny().ip("198.51.100.5"))
936 .build()
937 .unwrap();
938 assert_eq!(p.rules.len(), 1);
939 assert!(matches!(p.rules[0].direction, Direction::Any));
940 assert!(matches!(p.rules[0].action, Action::Deny));
941 match &p.rules[0].destination {
942 Destination::Cidr(net) => {
943 assert_eq!(net.to_string(), "198.51.100.5/32");
944 }
945 other => panic!("expected Cidr, got {other:?}"),
946 }
947 }
948
949 #[test]
952 fn invalid_ip_surfaces_at_build() {
953 let result = NetworkPolicy::builder()
954 .egress(|e| e.allow().ip("not-an-ip"))
955 .build();
956 match result {
957 Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
958 assert_eq!(raw, "not-an-ip");
959 }
960 other => panic!("expected InvalidIp, got {other:?}"),
961 }
962 }
963
964 #[test]
966 fn domain_parses_to_canonical_form() {
967 let p = NetworkPolicy::builder()
968 .egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
969 .build()
970 .unwrap();
971 match &p.rules[0].destination {
972 Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
973 other => panic!("expected Domain, got {other:?}"),
974 }
975 }
976
977 #[test]
979 fn invalid_port_range_surfaces_at_build() {
980 let result = NetworkPolicy::builder()
981 .egress(|e| e.tcp().port_range(443, 80).allow_public())
982 .build();
983 match result {
984 Err(BuildError::InvalidPortRange {
985 lo: 443, hi: 80, ..
986 }) => {}
987 other => panic!("expected InvalidPortRange, got {other:?}"),
988 }
989 }
990
991 #[test]
993 fn missing_direction_surfaces_at_build() {
994 let result = NetworkPolicy::builder()
995 .rule(|r| r.tcp().port(443).allow_public())
996 .build();
997 match result {
998 Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
999 other => panic!("expected DirectionNotSet, got {other:?}"),
1000 }
1001 }
1002
1003 #[test]
1005 fn icmp_in_ingress_rejected_at_build() {
1006 let result = NetworkPolicy::builder()
1007 .ingress(|i| i.icmpv4().allow_public())
1008 .build();
1009 match result {
1010 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1011 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1012 }
1013 }
1014
1015 #[test]
1017 fn icmp_in_any_direction_rejected_at_build() {
1018 let result = NetworkPolicy::builder()
1019 .any(|a| a.icmpv6().allow_public())
1020 .build();
1021 match result {
1022 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1023 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1024 }
1025 }
1026
1027 #[test]
1029 fn duplicate_protocols_dedupe() {
1030 let p = NetworkPolicy::builder()
1031 .egress(|e| e.tcp().tcp().udp().tcp().allow_public())
1032 .build()
1033 .unwrap();
1034 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
1035 }
1036
1037 #[test]
1040 fn explicit_group_uses_typed_argument() {
1041 let p = NetworkPolicy::builder()
1042 .egress(|e| e.allow().group(DestinationGroup::Multicast))
1043 .build()
1044 .unwrap();
1045 assert!(matches!(
1046 p.rules[0].destination,
1047 Destination::Group(DestinationGroup::Multicast)
1048 ));
1049 }
1050
1051 #[test]
1055 fn chain_form_compiles_without_explicit_return() {
1056 let _ = NetworkPolicy::builder()
1057 .rule(|r| r.egress().tcp().allow_public())
1058 .build()
1059 .unwrap();
1060 }
1061
1062 #[test]
1067 fn shadowed_rule_builds_and_is_detected() {
1068 let broader = Rule {
1069 direction: Direction::Egress,
1070 destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
1071 protocols: vec![],
1072 ports: vec![],
1073 action: Action::Allow,
1074 };
1075 let narrower = Rule {
1076 direction: Direction::Egress,
1077 destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
1078 protocols: vec![],
1079 ports: vec![],
1080 action: Action::Allow,
1081 };
1082 assert!(
1083 shadows(&broader, &narrower),
1084 "10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
1085 );
1086 assert!(
1087 !shadows(&narrower, &broader),
1088 "10.0.0.5/32 should NOT shadow 10.0.0.0/8"
1089 );
1090
1091 let _ = NetworkPolicy::builder()
1094 .egress(|e| e.allow().cidr("10.0.0.0/8"))
1095 .egress(|e| e.allow().cidr("10.0.0.5/32"))
1096 .build()
1097 .unwrap();
1098 }
1099
1100 #[test]
1103 fn direction_cover_relations() {
1104 use Direction::*;
1105 assert!(direction_covers(Any, Egress));
1106 assert!(direction_covers(Any, Ingress));
1107 assert!(direction_covers(Any, Any));
1108 assert!(direction_covers(Egress, Egress));
1109 assert!(!direction_covers(Egress, Ingress));
1110 assert!(!direction_covers(Egress, Any)); assert!(direction_covers(Ingress, Ingress));
1112 assert!(!direction_covers(Ingress, Egress));
1113 assert!(!direction_covers(Ingress, Any));
1114 }
1115
1116 #[test]
1123 fn deny_domains_produces_one_rule_per_name() {
1124 let p = NetworkPolicy::builder()
1125 .default_allow()
1126 .egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
1127 .build()
1128 .unwrap();
1129 assert_eq!(p.rules.len(), 2);
1130 for rule in &p.rules {
1131 assert_eq!(rule.action, Action::Deny);
1132 assert_eq!(rule.direction, Direction::Egress);
1133 assert!(rule.protocols.is_empty(), "no protocol filter");
1134 assert!(rule.ports.is_empty(), "no port filter");
1135 }
1136 assert!(matches!(
1137 &p.rules[0].destination,
1138 Destination::Domain(d) if d.as_str() == "evil.com",
1139 ));
1140 assert!(matches!(
1141 &p.rules[1].destination,
1142 Destination::Domain(d) if d.as_str() == "tracker.example",
1143 ));
1144 }
1145
1146 #[test]
1149 fn deny_domain_suffixes_produces_one_rule_per_suffix() {
1150 let p = NetworkPolicy::builder()
1151 .default_allow()
1152 .egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
1153 .build()
1154 .unwrap();
1155 assert_eq!(p.rules.len(), 2);
1156 assert!(matches!(
1157 &p.rules[0].destination,
1158 Destination::DomainSuffix(d) if d.as_str() == "ads.example",
1159 ));
1160 assert!(matches!(
1161 &p.rules[1].destination,
1162 Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
1163 ));
1164 }
1165
1166 #[test]
1169 fn deny_domains_inherits_protocol_and_port_filter() {
1170 let p = NetworkPolicy::builder()
1171 .default_allow()
1172 .egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
1173 .build()
1174 .unwrap();
1175 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
1176 assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
1177 }
1178
1179 #[test]
1182 fn allow_domains_produces_allow_rules() {
1183 let p = NetworkPolicy::builder()
1184 .default_deny()
1185 .egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
1186 .build()
1187 .unwrap();
1188 assert_eq!(p.rules.len(), 2);
1189 for rule in &p.rules {
1190 assert_eq!(rule.action, Action::Allow);
1191 }
1192 }
1193
1194 #[test]
1196 fn deny_domains_empty_input_is_noop() {
1197 let p = NetworkPolicy::builder()
1198 .default_allow()
1199 .egress(|e| e.deny_domains(Vec::<&str>::new()))
1200 .build()
1201 .unwrap();
1202 assert!(p.rules.is_empty());
1203 }
1204
1205 #[test]
1209 fn deny_domains_invalid_input_surfaces_at_build() {
1210 let result = NetworkPolicy::builder()
1211 .default_allow()
1212 .egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
1213 .build();
1214 match result {
1215 Err(BuildError::InvalidDomain {
1216 raw, rule_index, ..
1217 }) => {
1218 assert_eq!(raw, "not a domain!");
1219 assert_eq!(rule_index, 1);
1222 }
1223 other => panic!("expected InvalidDomain, got {other:?}"),
1224 }
1225 }
1226}