1use std::str::FromStr;
33
34use ipnetwork::IpNetwork;
35use microsandbox_types::{NetworkRateLimitDirection, RateLimitConfigError};
36
37use crate::secrets::config::SecretConfigError;
38
39use super::{
40 Action, Destination, DestinationGroup, Direction, DomainName, DomainNameError, NetworkPolicy,
41 PortRange, Protocol, Rule,
42};
43
44#[derive(Debug, Clone, thiserror::Error)]
58pub enum BuildError {
59 #[error(
61 "rule #{rule_index}: direction not set; call .egress(), .ingress(), or .any() before the rule-adder"
62 )]
63 DirectionNotSet { rule_index: usize },
64
65 #[error(
68 "rule #{rule_index}: destination not set; call .ip(), .cidr(), .domain(), .domain_suffix(), .group(), or .any() on the rule-destination builder"
69 )]
70 MissingDestination { rule_index: usize },
71
72 #[error("rule #{rule_index}: invalid IP address `{raw}`")]
75 InvalidIp { rule_index: usize, raw: String },
76
77 #[error("rule #{rule_index}: invalid CIDR `{raw}`")]
79 InvalidCidr { rule_index: usize, raw: String },
80
81 #[error("invalid IPv4 pool `{raw}`: prefix must be /30 or shorter")]
83 InvalidIpv4Pool { raw: String },
84
85 #[error("invalid IPv6 pool `{raw}`: prefix must be /64 or shorter")]
87 InvalidIpv6Pool { raw: String },
88
89 #[error("max_connections {configured} exceeds hard limit {limit}")]
91 MaxConnectionsExceeded {
92 configured: usize,
94 limit: usize,
96 },
97
98 #[error("intercept CA config is incomplete; set both cert_path and key_path")]
100 IncompleteInterceptCaConfig,
101
102 #[error("rule #{rule_index}: invalid domain `{raw}`: {source}")]
105 InvalidDomain {
106 rule_index: usize,
107 raw: String,
108 #[source]
109 source: DomainNameError,
110 },
111
112 #[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
114 InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
115
116 #[error(
120 "rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
121 )]
122 IngressDoesNotSupportIcmp { rule_index: usize },
123
124 #[error("{source}")]
126 InvalidSecretConfig {
127 #[from]
129 source: SecretConfigError,
130 },
131
132 #[error("{direction} rate limiter: {source}")]
134 InvalidRateLimitConfig {
135 direction: NetworkRateLimitDirection,
137 #[source]
139 source: RateLimitConfigError,
140 },
141
142 #[error("rate limiter must configure at least one of egress or ingress")]
144 EmptyNetworkRateLimiter,
145
146 #[error("{direction} rate limiter: {bucket}_burst requires the {bucket} bucket")]
148 RateLimitBurstWithoutBucket {
149 direction: NetworkRateLimitDirection,
151 bucket: &'static str,
153 },
154
155 #[error("{direction} rate limiter: {bucket} refill interval must be at least one millisecond")]
157 RateLimitRefillTooShort {
158 direction: NetworkRateLimitDirection,
160 bucket: &'static str,
162 },
163
164 #[error(
166 "{direction} rate limiter: {bucket} refill interval must be a whole number of milliseconds"
167 )]
168 RateLimitRefillPrecision {
169 direction: NetworkRateLimitDirection,
171 bucket: &'static str,
173 },
174
175 #[error("{direction} rate limiter: {bucket} refill interval overflows u64 milliseconds")]
177 RateLimitRefillTooLong {
178 direction: NetworkRateLimitDirection,
180 bucket: &'static str,
182 },
183}
184
185#[derive(Debug, Default)]
193pub struct NetworkPolicyBuilder {
194 default_egress: Option<Action>,
195 default_ingress: Option<Action>,
196 pending_rules: Vec<PendingRule>,
197 errors: Vec<BuildError>,
198}
199
200impl NetworkPolicyBuilder {
201 pub fn new() -> Self {
203 Self::default()
204 }
205
206 pub fn default_allow(mut self) -> Self {
208 self.default_egress = Some(Action::Allow);
209 self.default_ingress = Some(Action::Allow);
210 self
211 }
212
213 pub fn default_deny(mut self) -> Self {
215 self.default_egress = Some(Action::Deny);
216 self.default_ingress = Some(Action::Deny);
217 self
218 }
219
220 pub fn default_egress(mut self, action: Action) -> Self {
222 self.default_egress = Some(action);
223 self
224 }
225
226 pub fn default_ingress(mut self, action: Action) -> Self {
228 self.default_ingress = Some(action);
229 self
230 }
231
232 pub fn rule<F>(self, f: F) -> Self
235 where
236 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
237 {
238 self.with_rule_builder(None, f)
239 }
240
241 pub fn egress<F>(self, f: F) -> Self
243 where
244 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
245 {
246 self.with_rule_builder(Some(Direction::Egress), f)
247 }
248
249 pub fn ingress<F>(self, f: F) -> Self
251 where
252 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
253 {
254 self.with_rule_builder(Some(Direction::Ingress), f)
255 }
256
257 pub fn any<F>(self, f: F) -> Self
260 where
261 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
262 {
263 self.with_rule_builder(Some(Direction::Any), f)
264 }
265
266 fn with_rule_builder<F>(mut self, initial_direction: Option<Direction>, f: F) -> Self
267 where
268 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
269 {
270 let mut rb = RuleBuilder {
271 direction: initial_direction,
272 protocols: Vec::new(),
273 ports: Vec::new(),
274 pending_rules: Vec::new(),
275 errors: Vec::new(),
276 };
277 let _ = f(&mut rb);
278 self.pending_rules.append(&mut rb.pending_rules);
279 self.errors.append(&mut rb.errors);
280 self
281 }
282
283 pub fn build(self) -> Result<NetworkPolicy, BuildError> {
292 if let Some(err) = self.errors.into_iter().next() {
293 return Err(err);
294 }
295
296 let mut rules = Vec::with_capacity(self.pending_rules.len());
297 for (idx, pending) in self.pending_rules.into_iter().enumerate() {
298 let direction = pending
299 .direction
300 .ok_or(BuildError::DirectionNotSet { rule_index: idx })?;
301 let destination = pending.destination.parse(idx)?;
302
303 if matches!(direction, Direction::Ingress | Direction::Any)
304 && pending
305 .protocols
306 .iter()
307 .any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
308 {
309 return Err(BuildError::IngressDoesNotSupportIcmp { rule_index: idx });
310 }
311
312 rules.push(Rule {
313 direction,
314 destination,
315 protocols: pending.protocols,
316 ports: pending.ports,
317 action: pending.action,
318 });
319 }
320
321 warn_about_shadows(&rules);
322
323 Ok(NetworkPolicy {
324 default_egress: self.default_egress.unwrap_or_else(default_egress_default),
325 default_ingress: self.default_ingress.unwrap_or_else(default_ingress_default),
326 rules,
327 })
328 }
329}
330
331fn default_egress_default() -> Action {
335 Action::Deny
336}
337
338fn default_ingress_default() -> Action {
342 Action::Allow
343}
344
345#[derive(Debug)]
355pub struct RuleBuilder {
356 direction: Option<Direction>,
357 protocols: Vec<Protocol>,
358 ports: Vec<PortRange>,
359 pending_rules: Vec<PendingRule>,
360 errors: Vec<BuildError>,
361}
362
363impl RuleBuilder {
364 pub fn egress(&mut self) -> &mut Self {
368 self.direction = Some(Direction::Egress);
369 self
370 }
371
372 pub fn ingress(&mut self) -> &mut Self {
374 self.direction = Some(Direction::Ingress);
375 self
376 }
377
378 pub fn any(&mut self) -> &mut Self {
381 self.direction = Some(Direction::Any);
382 self
383 }
384
385 pub fn tcp(&mut self) -> &mut Self {
389 self.add_protocol(Protocol::Tcp)
390 }
391
392 pub fn udp(&mut self) -> &mut Self {
394 self.add_protocol(Protocol::Udp)
395 }
396
397 pub fn icmpv4(&mut self) -> &mut Self {
401 self.add_protocol(Protocol::Icmpv4)
402 }
403
404 pub fn icmpv6(&mut self) -> &mut Self {
406 self.add_protocol(Protocol::Icmpv6)
407 }
408
409 fn add_protocol(&mut self, p: Protocol) -> &mut Self {
410 if !self.protocols.contains(&p) {
411 self.protocols.push(p);
412 }
413 self
414 }
415
416 pub fn port(&mut self, port: u16) -> &mut Self {
420 let pr = PortRange::single(port);
421 if !self.ports.contains(&pr) {
422 self.ports.push(pr);
423 }
424 self
425 }
426
427 pub fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self {
430 if lo > hi {
431 self.errors.push(BuildError::InvalidPortRange {
432 rule_index: self.pending_rules.len(),
433 lo,
434 hi,
435 });
436 return self;
437 }
438 let pr = PortRange::range(lo, hi);
439 if !self.ports.contains(&pr) {
440 self.ports.push(pr);
441 }
442 self
443 }
444
445 pub fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self {
448 for p in ports {
449 self.port(p);
450 }
451 self
452 }
453
454 pub fn allow_public(&mut self) -> &mut Self {
458 self.commit_group(Action::Allow, DestinationGroup::Public)
459 }
460
461 pub fn deny_public(&mut self) -> &mut Self {
463 self.commit_group(Action::Deny, DestinationGroup::Public)
464 }
465
466 pub fn allow_private(&mut self) -> &mut Self {
468 self.commit_group(Action::Allow, DestinationGroup::Private)
469 }
470
471 pub fn deny_private(&mut self) -> &mut Self {
473 self.commit_group(Action::Deny, DestinationGroup::Private)
474 }
475
476 pub fn allow_loopback(&mut self) -> &mut Self {
485 self.commit_group(Action::Allow, DestinationGroup::Loopback)
486 }
487
488 pub fn deny_loopback(&mut self) -> &mut Self {
496 self.commit_group(Action::Deny, DestinationGroup::Loopback)
497 }
498
499 pub fn allow_link_local(&mut self) -> &mut Self {
503 self.commit_group(Action::Allow, DestinationGroup::LinkLocal)
504 }
505
506 pub fn deny_link_local(&mut self) -> &mut Self {
508 self.commit_group(Action::Deny, DestinationGroup::LinkLocal)
509 }
510
511 pub fn allow_meta(&mut self) -> &mut Self {
514 self.commit_group(Action::Allow, DestinationGroup::Metadata)
515 }
516
517 pub fn deny_meta(&mut self) -> &mut Self {
519 self.commit_group(Action::Deny, DestinationGroup::Metadata)
520 }
521
522 pub fn allow_multicast(&mut self) -> &mut Self {
524 self.commit_group(Action::Allow, DestinationGroup::Multicast)
525 }
526
527 pub fn deny_multicast(&mut self) -> &mut Self {
529 self.commit_group(Action::Deny, DestinationGroup::Multicast)
530 }
531
532 pub fn allow_host(&mut self) -> &mut Self {
537 self.commit_group(Action::Allow, DestinationGroup::Host)
538 }
539
540 pub fn deny_host(&mut self) -> &mut Self {
542 self.commit_group(Action::Deny, DestinationGroup::Host)
543 }
544
545 pub fn allow_local(&mut self) -> &mut Self {
558 self.allow_loopback();
559 self.allow_link_local();
560 self.allow_host();
561 self
562 }
563
564 pub fn deny_local(&mut self) -> &mut Self {
567 self.deny_loopback();
568 self.deny_link_local();
569 self.deny_host();
570 self
571 }
572
573 pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
577 where
578 I: IntoIterator<Item = S>,
579 S: Into<String>,
580 {
581 for name in names {
582 self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
583 }
584 self
585 }
586
587 pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
589 where
590 I: IntoIterator<Item = S>,
591 S: Into<String>,
592 {
593 for name in names {
594 self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
595 }
596 self
597 }
598
599 pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
601 where
602 I: IntoIterator<Item = S>,
603 S: Into<String>,
604 {
605 for suffix in suffixes {
606 self.commit_rule(
607 Action::Allow,
608 PendingDestination::DomainSuffix(suffix.into()),
609 );
610 }
611 self
612 }
613
614 pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
616 where
617 I: IntoIterator<Item = S>,
618 S: Into<String>,
619 {
620 for suffix in suffixes {
621 self.commit_rule(
622 Action::Deny,
623 PendingDestination::DomainSuffix(suffix.into()),
624 );
625 }
626 self
627 }
628
629 pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
636 RuleDestinationBuilder {
637 rule_builder: self,
638 action: Action::Allow,
639 }
640 }
641
642 pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
644 RuleDestinationBuilder {
645 rule_builder: self,
646 action: Action::Deny,
647 }
648 }
649
650 fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
653 self.commit_rule(
654 action,
655 PendingDestination::Resolved(Destination::Group(group)),
656 );
657 self
658 }
659
660 fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
661 self.pending_rules.push(PendingRule {
662 direction: self.direction,
663 destination,
664 protocols: self.protocols.clone(),
665 ports: self.ports.clone(),
666 action,
667 });
668 }
669}
670
671#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
681pub struct RuleDestinationBuilder<'a> {
682 rule_builder: &'a mut RuleBuilder,
683 action: Action,
684}
685
686impl<'a> RuleDestinationBuilder<'a> {
687 pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
691 self.rule_builder
692 .commit_rule(self.action, PendingDestination::Ip(ip.into()));
693 self.rule_builder
694 }
695
696 pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
698 self.rule_builder
699 .commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
700 self.rule_builder
701 }
702
703 pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
707 self.rule_builder
708 .commit_rule(self.action, PendingDestination::Domain(domain.into()));
709 self.rule_builder
710 }
711
712 pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
715 self.rule_builder
716 .commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
717 self.rule_builder
718 }
719
720 pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
722 self.rule_builder.commit_rule(
723 self.action,
724 PendingDestination::Resolved(Destination::Group(group)),
725 );
726 self.rule_builder
727 }
728
729 pub fn any(self) -> &'a mut RuleBuilder {
731 self.rule_builder
732 .commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
733 self.rule_builder
734 }
735}
736
737#[derive(Debug, Clone)]
742struct PendingRule {
743 direction: Option<Direction>,
744 destination: PendingDestination,
745 protocols: Vec<Protocol>,
746 ports: Vec<PortRange>,
747 action: Action,
748}
749
750#[derive(Debug, Clone)]
751enum PendingDestination {
752 Resolved(Destination),
754 Ip(String),
755 Cidr(String),
756 Domain(String),
757 DomainSuffix(String),
758}
759
760impl PendingDestination {
761 fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
762 match self {
763 PendingDestination::Resolved(d) => Ok(d.clone()),
764 PendingDestination::Ip(raw) => {
765 let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
766 rule_index: idx,
767 raw: raw.clone(),
768 })?;
769 let prefix = if ip.is_ipv4() { 32 } else { 128 };
772 let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
773 rule_index: idx,
774 raw: raw.clone(),
775 })?;
776 Ok(Destination::Cidr(net))
777 }
778 PendingDestination::Cidr(raw) => {
779 let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
780 rule_index: idx,
781 raw: raw.clone(),
782 })?;
783 Ok(Destination::Cidr(net))
784 }
785 PendingDestination::Domain(raw) => {
786 let name =
787 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
788 rule_index: idx,
789 raw: raw.clone(),
790 source,
791 })?;
792 Ok(Destination::Domain(name))
793 }
794 PendingDestination::DomainSuffix(raw) => {
795 let name =
796 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
797 rule_index: idx,
798 raw: raw.clone(),
799 source,
800 })?;
801 let name = name
802 .try_into_suffix()
803 .map_err(|source| BuildError::InvalidDomain {
804 rule_index: idx,
805 raw: raw.clone(),
806 source,
807 })?;
808 Ok(Destination::DomainSuffix(name))
809 }
810 }
811 }
812}
813
814fn warn_about_shadows(rules: &[Rule]) {
826 for (i, later) in rules.iter().enumerate() {
827 for (j, earlier) in rules.iter().take(i).enumerate() {
828 if shadows(earlier, later) {
829 tracing::warn!(
830 shadowed_index = i,
831 shadowed_by = j,
832 "rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
833 later.direction,
834 later.action,
835 later.destination,
836 earlier.direction,
837 earlier.action,
838 earlier.destination,
839 );
840 }
841 }
842 }
843}
844
845fn shadows(earlier: &Rule, later: &Rule) -> bool {
848 direction_covers(earlier.direction, later.direction)
849 && destination_covers(&earlier.destination, &later.destination)
850 && protocol_set_covers(&earlier.protocols, &later.protocols)
851 && port_set_covers(&earlier.ports, &later.ports)
852}
853
854fn direction_covers(earlier: Direction, later: Direction) -> bool {
855 matches!(
856 (earlier, later),
857 (Direction::Any, _)
858 | (Direction::Egress, Direction::Egress)
859 | (Direction::Ingress, Direction::Ingress)
860 )
861}
862
863fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
864 match (earlier, later) {
865 (Destination::Any, _) => true,
866 (Destination::Group(eg), Destination::Group(lg)) => eg == lg,
867 (Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
868 _ => false,
870 }
871}
872
873fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
874 match (outer, inner) {
875 (IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
876 (IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
877 _ => false,
878 }
879}
880
881fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
882 if earlier.is_empty() {
883 return true; }
885 if later.is_empty() {
886 return false; }
888 later.iter().all(|p| earlier.contains(p))
889}
890
891fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
892 if earlier.is_empty() {
893 return true;
894 }
895 if later.is_empty() {
896 return false;
897 }
898 later.iter().all(|lp| {
899 earlier
900 .iter()
901 .any(|ep| ep.start <= lp.start && lp.end <= ep.end)
902 })
903}
904
905impl NetworkPolicy {
910 pub fn builder() -> NetworkPolicyBuilder {
912 NetworkPolicyBuilder::new()
913 }
914}
915
916#[cfg(test)]
921mod tests {
922 use super::*;
923
924 #[test]
927 fn empty_builder_yields_asymmetric_default() {
928 let p = NetworkPolicy::builder().build().unwrap();
929 assert!(matches!(p.default_egress, Action::Deny));
930 assert!(matches!(p.default_ingress, Action::Allow));
931 assert!(p.rules.is_empty());
932 }
933
934 #[test]
937 fn defaults_set_and_override() {
938 let p = NetworkPolicy::builder()
939 .default_deny()
940 .default_ingress(Action::Allow)
941 .build()
942 .unwrap();
943 assert!(matches!(p.default_egress, Action::Deny));
944 assert!(matches!(p.default_ingress, Action::Allow));
945 }
946
947 #[test]
950 fn egress_closure_commits_one_rule_per_shortcut() {
951 let p = NetworkPolicy::builder()
952 .egress(|e| e.tcp().port(443).allow_public().allow_private())
953 .build()
954 .unwrap();
955 assert_eq!(p.rules.len(), 2);
956 assert!(matches!(p.rules[0].direction, Direction::Egress));
957 assert!(matches!(p.rules[0].action, Action::Allow));
958 assert!(matches!(
959 p.rules[0].destination,
960 Destination::Group(DestinationGroup::Public)
961 ));
962 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
963 assert_eq!(p.rules[0].ports.len(), 1);
964 assert!(matches!(
965 p.rules[1].destination,
966 Destination::Group(DestinationGroup::Private)
967 ));
968 }
969
970 #[test]
972 fn allow_local_expands_to_three_groups() {
973 let p = NetworkPolicy::builder()
974 .egress(|e| e.allow_local())
975 .build()
976 .unwrap();
977 assert_eq!(p.rules.len(), 3);
978 let groups: Vec<_> = p
979 .rules
980 .iter()
981 .map(|r| match &r.destination {
982 Destination::Group(g) => *g,
983 other => panic!("unexpected destination {other:?}"),
984 })
985 .collect();
986 assert_eq!(
987 groups,
988 vec![
989 DestinationGroup::Loopback,
990 DestinationGroup::LinkLocal,
991 DestinationGroup::Host,
992 ]
993 );
994 }
995
996 #[test]
999 fn explicit_ip_parses_at_build() {
1000 let p = NetworkPolicy::builder()
1001 .any(|a| a.deny().ip("198.51.100.5"))
1002 .build()
1003 .unwrap();
1004 assert_eq!(p.rules.len(), 1);
1005 assert!(matches!(p.rules[0].direction, Direction::Any));
1006 assert!(matches!(p.rules[0].action, Action::Deny));
1007 match &p.rules[0].destination {
1008 Destination::Cidr(net) => {
1009 assert_eq!(net.to_string(), "198.51.100.5/32");
1010 }
1011 other => panic!("expected Cidr, got {other:?}"),
1012 }
1013 }
1014
1015 #[test]
1018 fn invalid_ip_surfaces_at_build() {
1019 let result = NetworkPolicy::builder()
1020 .egress(|e| e.allow().ip("not-an-ip"))
1021 .build();
1022 match result {
1023 Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
1024 assert_eq!(raw, "not-an-ip");
1025 }
1026 other => panic!("expected InvalidIp, got {other:?}"),
1027 }
1028 }
1029
1030 #[test]
1032 fn domain_parses_to_canonical_form() {
1033 let p = NetworkPolicy::builder()
1034 .egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
1035 .build()
1036 .unwrap();
1037 match &p.rules[0].destination {
1038 Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
1039 other => panic!("expected Domain, got {other:?}"),
1040 }
1041 }
1042
1043 #[test]
1045 fn invalid_port_range_surfaces_at_build() {
1046 let result = NetworkPolicy::builder()
1047 .egress(|e| e.tcp().port_range(443, 80).allow_public())
1048 .build();
1049 match result {
1050 Err(BuildError::InvalidPortRange {
1051 lo: 443, hi: 80, ..
1052 }) => {}
1053 other => panic!("expected InvalidPortRange, got {other:?}"),
1054 }
1055 }
1056
1057 #[test]
1059 fn missing_direction_surfaces_at_build() {
1060 let result = NetworkPolicy::builder()
1061 .rule(|r| r.tcp().port(443).allow_public())
1062 .build();
1063 match result {
1064 Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
1065 other => panic!("expected DirectionNotSet, got {other:?}"),
1066 }
1067 }
1068
1069 #[test]
1071 fn icmp_in_ingress_rejected_at_build() {
1072 let result = NetworkPolicy::builder()
1073 .ingress(|i| i.icmpv4().allow_public())
1074 .build();
1075 match result {
1076 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1077 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1078 }
1079 }
1080
1081 #[test]
1083 fn icmp_in_any_direction_rejected_at_build() {
1084 let result = NetworkPolicy::builder()
1085 .any(|a| a.icmpv6().allow_public())
1086 .build();
1087 match result {
1088 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1089 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1090 }
1091 }
1092
1093 #[test]
1095 fn duplicate_protocols_dedupe() {
1096 let p = NetworkPolicy::builder()
1097 .egress(|e| e.tcp().tcp().udp().tcp().allow_public())
1098 .build()
1099 .unwrap();
1100 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
1101 }
1102
1103 #[test]
1106 fn explicit_group_uses_typed_argument() {
1107 let p = NetworkPolicy::builder()
1108 .egress(|e| e.allow().group(DestinationGroup::Multicast))
1109 .build()
1110 .unwrap();
1111 assert!(matches!(
1112 p.rules[0].destination,
1113 Destination::Group(DestinationGroup::Multicast)
1114 ));
1115 }
1116
1117 #[test]
1121 fn chain_form_compiles_without_explicit_return() {
1122 let _ = NetworkPolicy::builder()
1123 .rule(|r| r.egress().tcp().allow_public())
1124 .build()
1125 .unwrap();
1126 }
1127
1128 #[test]
1133 fn shadowed_rule_builds_and_is_detected() {
1134 let broader = Rule {
1135 direction: Direction::Egress,
1136 destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
1137 protocols: vec![],
1138 ports: vec![],
1139 action: Action::Allow,
1140 };
1141 let narrower = Rule {
1142 direction: Direction::Egress,
1143 destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
1144 protocols: vec![],
1145 ports: vec![],
1146 action: Action::Allow,
1147 };
1148 assert!(
1149 shadows(&broader, &narrower),
1150 "10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
1151 );
1152 assert!(
1153 !shadows(&narrower, &broader),
1154 "10.0.0.5/32 should NOT shadow 10.0.0.0/8"
1155 );
1156
1157 let _ = NetworkPolicy::builder()
1160 .egress(|e| e.allow().cidr("10.0.0.0/8"))
1161 .egress(|e| e.allow().cidr("10.0.0.5/32"))
1162 .build()
1163 .unwrap();
1164 }
1165
1166 #[test]
1169 fn direction_cover_relations() {
1170 use Direction::*;
1171 assert!(direction_covers(Any, Egress));
1172 assert!(direction_covers(Any, Ingress));
1173 assert!(direction_covers(Any, Any));
1174 assert!(direction_covers(Egress, Egress));
1175 assert!(!direction_covers(Egress, Ingress));
1176 assert!(!direction_covers(Egress, Any)); assert!(direction_covers(Ingress, Ingress));
1178 assert!(!direction_covers(Ingress, Egress));
1179 assert!(!direction_covers(Ingress, Any));
1180 }
1181
1182 #[test]
1189 fn deny_domains_produces_one_rule_per_name() {
1190 let p = NetworkPolicy::builder()
1191 .default_allow()
1192 .egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
1193 .build()
1194 .unwrap();
1195 assert_eq!(p.rules.len(), 2);
1196 for rule in &p.rules {
1197 assert_eq!(rule.action, Action::Deny);
1198 assert_eq!(rule.direction, Direction::Egress);
1199 assert!(rule.protocols.is_empty(), "no protocol filter");
1200 assert!(rule.ports.is_empty(), "no port filter");
1201 }
1202 assert!(matches!(
1203 &p.rules[0].destination,
1204 Destination::Domain(d) if d.as_str() == "evil.com",
1205 ));
1206 assert!(matches!(
1207 &p.rules[1].destination,
1208 Destination::Domain(d) if d.as_str() == "tracker.example",
1209 ));
1210 }
1211
1212 #[test]
1215 fn deny_domain_suffixes_produces_one_rule_per_suffix() {
1216 let p = NetworkPolicy::builder()
1217 .default_allow()
1218 .egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
1219 .build()
1220 .unwrap();
1221 assert_eq!(p.rules.len(), 2);
1222 assert!(matches!(
1223 &p.rules[0].destination,
1224 Destination::DomainSuffix(d) if d.as_str() == "ads.example",
1225 ));
1226 assert!(matches!(
1227 &p.rules[1].destination,
1228 Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
1229 ));
1230 }
1231
1232 #[test]
1235 fn deny_domains_inherits_protocol_and_port_filter() {
1236 let p = NetworkPolicy::builder()
1237 .default_allow()
1238 .egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
1239 .build()
1240 .unwrap();
1241 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
1242 assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
1243 }
1244
1245 #[test]
1248 fn allow_domains_produces_allow_rules() {
1249 let p = NetworkPolicy::builder()
1250 .default_deny()
1251 .egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
1252 .build()
1253 .unwrap();
1254 assert_eq!(p.rules.len(), 2);
1255 for rule in &p.rules {
1256 assert_eq!(rule.action, Action::Allow);
1257 }
1258 }
1259
1260 #[test]
1262 fn deny_domains_empty_input_is_noop() {
1263 let p = NetworkPolicy::builder()
1264 .default_allow()
1265 .egress(|e| e.deny_domains(Vec::<&str>::new()))
1266 .build()
1267 .unwrap();
1268 assert!(p.rules.is_empty());
1269 }
1270
1271 #[test]
1275 fn deny_domains_invalid_input_surfaces_at_build() {
1276 let result = NetworkPolicy::builder()
1277 .default_allow()
1278 .egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
1279 .build();
1280 match result {
1281 Err(BuildError::InvalidDomain {
1282 raw, rule_index, ..
1283 }) => {
1284 assert_eq!(raw, "not a domain!");
1285 assert_eq!(rule_index, 1);
1288 }
1289 other => panic!("expected InvalidDomain, got {other:?}"),
1290 }
1291 }
1292}