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("invalid outbound proxy: {reason}")]
91 InvalidOutboundProxy {
92 reason: String,
94 },
95
96 #[error("max_connections {configured} exceeds hard limit {limit}")]
98 MaxConnectionsExceeded {
99 configured: usize,
101 limit: usize,
103 },
104
105 #[error("intercept CA config is incomplete; set both cert_path and key_path")]
107 IncompleteInterceptCaConfig,
108
109 #[error("rule #{rule_index}: invalid domain `{raw}`: {source}")]
112 InvalidDomain {
113 rule_index: usize,
114 raw: String,
115 #[source]
116 source: DomainNameError,
117 },
118
119 #[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
121 InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
122
123 #[error(
127 "rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
128 )]
129 IngressDoesNotSupportIcmp { rule_index: usize },
130
131 #[error("{source}")]
133 InvalidSecretConfig {
134 #[from]
136 source: SecretConfigError,
137 },
138
139 #[error("{direction} rate limiter: {source}")]
141 InvalidRateLimitConfig {
142 direction: NetworkRateLimitDirection,
144 #[source]
146 source: RateLimitConfigError,
147 },
148
149 #[error("rate limiter must configure at least one of egress or ingress")]
151 EmptyNetworkRateLimiter,
152
153 #[error("{direction} rate limiter: {bucket}_burst requires the {bucket} bucket")]
155 RateLimitBurstWithoutBucket {
156 direction: NetworkRateLimitDirection,
158 bucket: &'static str,
160 },
161
162 #[error("{direction} rate limiter: {bucket} refill interval must be at least one millisecond")]
164 RateLimitRefillTooShort {
165 direction: NetworkRateLimitDirection,
167 bucket: &'static str,
169 },
170
171 #[error(
173 "{direction} rate limiter: {bucket} refill interval must be a whole number of milliseconds"
174 )]
175 RateLimitRefillPrecision {
176 direction: NetworkRateLimitDirection,
178 bucket: &'static str,
180 },
181
182 #[error("{direction} rate limiter: {bucket} refill interval overflows u64 milliseconds")]
184 RateLimitRefillTooLong {
185 direction: NetworkRateLimitDirection,
187 bucket: &'static str,
189 },
190}
191
192#[derive(Debug, Default)]
200pub struct NetworkPolicyBuilder {
201 default_egress: Option<Action>,
202 default_ingress: Option<Action>,
203 pending_rules: Vec<PendingRule>,
204 errors: Vec<BuildError>,
205}
206
207impl NetworkPolicyBuilder {
208 pub fn new() -> Self {
210 Self::default()
211 }
212
213 pub fn default_allow(mut self) -> Self {
215 self.default_egress = Some(Action::Allow);
216 self.default_ingress = Some(Action::Allow);
217 self
218 }
219
220 pub fn default_deny(mut self) -> Self {
222 self.default_egress = Some(Action::Deny);
223 self.default_ingress = Some(Action::Deny);
224 self
225 }
226
227 pub fn default_egress(mut self, action: Action) -> Self {
229 self.default_egress = Some(action);
230 self
231 }
232
233 pub fn default_ingress(mut self, action: Action) -> Self {
235 self.default_ingress = Some(action);
236 self
237 }
238
239 pub fn rule<F>(self, f: F) -> Self
242 where
243 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
244 {
245 self.with_rule_builder(None, f)
246 }
247
248 pub fn egress<F>(self, f: F) -> Self
250 where
251 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
252 {
253 self.with_rule_builder(Some(Direction::Egress), f)
254 }
255
256 pub fn ingress<F>(self, f: F) -> Self
258 where
259 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
260 {
261 self.with_rule_builder(Some(Direction::Ingress), f)
262 }
263
264 pub fn any<F>(self, f: F) -> Self
267 where
268 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
269 {
270 self.with_rule_builder(Some(Direction::Any), f)
271 }
272
273 fn with_rule_builder<F>(mut self, initial_direction: Option<Direction>, f: F) -> Self
274 where
275 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
276 {
277 let mut rb = RuleBuilder {
278 direction: initial_direction,
279 protocols: Vec::new(),
280 ports: Vec::new(),
281 pending_rules: Vec::new(),
282 errors: Vec::new(),
283 };
284 let _ = f(&mut rb);
285 self.pending_rules.append(&mut rb.pending_rules);
286 self.errors.append(&mut rb.errors);
287 self
288 }
289
290 pub fn build(self) -> Result<NetworkPolicy, BuildError> {
299 if let Some(err) = self.errors.into_iter().next() {
300 return Err(err);
301 }
302
303 let mut rules = Vec::with_capacity(self.pending_rules.len());
304 for (idx, pending) in self.pending_rules.into_iter().enumerate() {
305 let direction = pending
306 .direction
307 .ok_or(BuildError::DirectionNotSet { rule_index: idx })?;
308 let destination = pending.destination.parse(idx)?;
309
310 if matches!(direction, Direction::Ingress | Direction::Any)
311 && pending
312 .protocols
313 .iter()
314 .any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
315 {
316 return Err(BuildError::IngressDoesNotSupportIcmp { rule_index: idx });
317 }
318
319 rules.push(Rule {
320 direction,
321 destination,
322 protocols: pending.protocols,
323 ports: pending.ports,
324 action: pending.action,
325 });
326 }
327
328 warn_about_shadows(&rules);
329
330 Ok(NetworkPolicy {
331 default_egress: self.default_egress.unwrap_or_else(default_egress_default),
332 default_ingress: self.default_ingress.unwrap_or_else(default_ingress_default),
333 rules,
334 })
335 }
336}
337
338fn default_egress_default() -> Action {
342 Action::Deny
343}
344
345fn default_ingress_default() -> Action {
349 Action::Allow
350}
351
352#[derive(Debug)]
362pub struct RuleBuilder {
363 direction: Option<Direction>,
364 protocols: Vec<Protocol>,
365 ports: Vec<PortRange>,
366 pending_rules: Vec<PendingRule>,
367 errors: Vec<BuildError>,
368}
369
370impl RuleBuilder {
371 pub fn egress(&mut self) -> &mut Self {
375 self.direction = Some(Direction::Egress);
376 self
377 }
378
379 pub fn ingress(&mut self) -> &mut Self {
381 self.direction = Some(Direction::Ingress);
382 self
383 }
384
385 pub fn any(&mut self) -> &mut Self {
388 self.direction = Some(Direction::Any);
389 self
390 }
391
392 pub fn tcp(&mut self) -> &mut Self {
396 self.add_protocol(Protocol::Tcp)
397 }
398
399 pub fn udp(&mut self) -> &mut Self {
401 self.add_protocol(Protocol::Udp)
402 }
403
404 pub fn icmpv4(&mut self) -> &mut Self {
408 self.add_protocol(Protocol::Icmpv4)
409 }
410
411 pub fn icmpv6(&mut self) -> &mut Self {
413 self.add_protocol(Protocol::Icmpv6)
414 }
415
416 fn add_protocol(&mut self, p: Protocol) -> &mut Self {
417 if !self.protocols.contains(&p) {
418 self.protocols.push(p);
419 }
420 self
421 }
422
423 pub fn port(&mut self, port: u16) -> &mut Self {
427 let pr = PortRange::single(port);
428 if !self.ports.contains(&pr) {
429 self.ports.push(pr);
430 }
431 self
432 }
433
434 pub fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self {
437 if lo > hi {
438 self.errors.push(BuildError::InvalidPortRange {
439 rule_index: self.pending_rules.len(),
440 lo,
441 hi,
442 });
443 return self;
444 }
445 let pr = PortRange::range(lo, hi);
446 if !self.ports.contains(&pr) {
447 self.ports.push(pr);
448 }
449 self
450 }
451
452 pub fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self {
455 for p in ports {
456 self.port(p);
457 }
458 self
459 }
460
461 pub fn allow_public(&mut self) -> &mut Self {
465 self.commit_group(Action::Allow, DestinationGroup::Public)
466 }
467
468 pub fn deny_public(&mut self) -> &mut Self {
470 self.commit_group(Action::Deny, DestinationGroup::Public)
471 }
472
473 pub fn allow_private(&mut self) -> &mut Self {
475 self.commit_group(Action::Allow, DestinationGroup::Private)
476 }
477
478 pub fn deny_private(&mut self) -> &mut Self {
480 self.commit_group(Action::Deny, DestinationGroup::Private)
481 }
482
483 pub fn allow_loopback(&mut self) -> &mut Self {
492 self.commit_group(Action::Allow, DestinationGroup::Loopback)
493 }
494
495 pub fn deny_loopback(&mut self) -> &mut Self {
503 self.commit_group(Action::Deny, DestinationGroup::Loopback)
504 }
505
506 pub fn allow_link_local(&mut self) -> &mut Self {
510 self.commit_group(Action::Allow, DestinationGroup::LinkLocal)
511 }
512
513 pub fn deny_link_local(&mut self) -> &mut Self {
515 self.commit_group(Action::Deny, DestinationGroup::LinkLocal)
516 }
517
518 pub fn allow_meta(&mut self) -> &mut Self {
521 self.commit_group(Action::Allow, DestinationGroup::Metadata)
522 }
523
524 pub fn deny_meta(&mut self) -> &mut Self {
526 self.commit_group(Action::Deny, DestinationGroup::Metadata)
527 }
528
529 pub fn allow_multicast(&mut self) -> &mut Self {
531 self.commit_group(Action::Allow, DestinationGroup::Multicast)
532 }
533
534 pub fn deny_multicast(&mut self) -> &mut Self {
536 self.commit_group(Action::Deny, DestinationGroup::Multicast)
537 }
538
539 pub fn allow_host(&mut self) -> &mut Self {
544 self.commit_group(Action::Allow, DestinationGroup::Host)
545 }
546
547 pub fn deny_host(&mut self) -> &mut Self {
549 self.commit_group(Action::Deny, DestinationGroup::Host)
550 }
551
552 pub fn allow_local(&mut self) -> &mut Self {
565 self.allow_loopback();
566 self.allow_link_local();
567 self.allow_host();
568 self
569 }
570
571 pub fn deny_local(&mut self) -> &mut Self {
574 self.deny_loopback();
575 self.deny_link_local();
576 self.deny_host();
577 self
578 }
579
580 pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
584 where
585 I: IntoIterator<Item = S>,
586 S: Into<String>,
587 {
588 for name in names {
589 self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
590 }
591 self
592 }
593
594 pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
596 where
597 I: IntoIterator<Item = S>,
598 S: Into<String>,
599 {
600 for name in names {
601 self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
602 }
603 self
604 }
605
606 pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
608 where
609 I: IntoIterator<Item = S>,
610 S: Into<String>,
611 {
612 for suffix in suffixes {
613 self.commit_rule(
614 Action::Allow,
615 PendingDestination::DomainSuffix(suffix.into()),
616 );
617 }
618 self
619 }
620
621 pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
623 where
624 I: IntoIterator<Item = S>,
625 S: Into<String>,
626 {
627 for suffix in suffixes {
628 self.commit_rule(
629 Action::Deny,
630 PendingDestination::DomainSuffix(suffix.into()),
631 );
632 }
633 self
634 }
635
636 pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
643 RuleDestinationBuilder {
644 rule_builder: self,
645 action: Action::Allow,
646 }
647 }
648
649 pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
651 RuleDestinationBuilder {
652 rule_builder: self,
653 action: Action::Deny,
654 }
655 }
656
657 fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
660 self.commit_rule(
661 action,
662 PendingDestination::Resolved(Destination::Group(group)),
663 );
664 self
665 }
666
667 fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
668 self.pending_rules.push(PendingRule {
669 direction: self.direction,
670 destination,
671 protocols: self.protocols.clone(),
672 ports: self.ports.clone(),
673 action,
674 });
675 }
676}
677
678#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
688pub struct RuleDestinationBuilder<'a> {
689 rule_builder: &'a mut RuleBuilder,
690 action: Action,
691}
692
693impl<'a> RuleDestinationBuilder<'a> {
694 pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
698 self.rule_builder
699 .commit_rule(self.action, PendingDestination::Ip(ip.into()));
700 self.rule_builder
701 }
702
703 pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
705 self.rule_builder
706 .commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
707 self.rule_builder
708 }
709
710 pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
714 self.rule_builder
715 .commit_rule(self.action, PendingDestination::Domain(domain.into()));
716 self.rule_builder
717 }
718
719 pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
722 self.rule_builder
723 .commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
724 self.rule_builder
725 }
726
727 pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
729 self.rule_builder.commit_rule(
730 self.action,
731 PendingDestination::Resolved(Destination::Group(group)),
732 );
733 self.rule_builder
734 }
735
736 pub fn any(self) -> &'a mut RuleBuilder {
738 self.rule_builder
739 .commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
740 self.rule_builder
741 }
742}
743
744#[derive(Debug, Clone)]
749struct PendingRule {
750 direction: Option<Direction>,
751 destination: PendingDestination,
752 protocols: Vec<Protocol>,
753 ports: Vec<PortRange>,
754 action: Action,
755}
756
757#[derive(Debug, Clone)]
758enum PendingDestination {
759 Resolved(Destination),
761 Ip(String),
762 Cidr(String),
763 Domain(String),
764 DomainSuffix(String),
765}
766
767impl PendingDestination {
768 fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
769 match self {
770 PendingDestination::Resolved(d) => Ok(d.clone()),
771 PendingDestination::Ip(raw) => {
772 let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
773 rule_index: idx,
774 raw: raw.clone(),
775 })?;
776 let prefix = if ip.is_ipv4() { 32 } else { 128 };
779 let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
780 rule_index: idx,
781 raw: raw.clone(),
782 })?;
783 Ok(Destination::Cidr(net))
784 }
785 PendingDestination::Cidr(raw) => {
786 let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
787 rule_index: idx,
788 raw: raw.clone(),
789 })?;
790 Ok(Destination::Cidr(net))
791 }
792 PendingDestination::Domain(raw) => {
793 let name =
794 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
795 rule_index: idx,
796 raw: raw.clone(),
797 source,
798 })?;
799 Ok(Destination::Domain(name))
800 }
801 PendingDestination::DomainSuffix(raw) => {
802 let name =
803 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
804 rule_index: idx,
805 raw: raw.clone(),
806 source,
807 })?;
808 let name = name
809 .try_into_suffix()
810 .map_err(|source| BuildError::InvalidDomain {
811 rule_index: idx,
812 raw: raw.clone(),
813 source,
814 })?;
815 Ok(Destination::DomainSuffix(name))
816 }
817 }
818 }
819}
820
821fn warn_about_shadows(rules: &[Rule]) {
833 for (i, later) in rules.iter().enumerate() {
834 for (j, earlier) in rules.iter().take(i).enumerate() {
835 if shadows(earlier, later) {
836 tracing::warn!(
837 shadowed_index = i,
838 shadowed_by = j,
839 "rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
840 later.direction,
841 later.action,
842 later.destination,
843 earlier.direction,
844 earlier.action,
845 earlier.destination,
846 );
847 }
848 }
849 }
850}
851
852fn shadows(earlier: &Rule, later: &Rule) -> bool {
855 direction_covers(earlier.direction, later.direction)
856 && destination_covers(&earlier.destination, &later.destination)
857 && protocol_set_covers(&earlier.protocols, &later.protocols)
858 && port_set_covers(&earlier.ports, &later.ports)
859}
860
861fn direction_covers(earlier: Direction, later: Direction) -> bool {
862 matches!(
863 (earlier, later),
864 (Direction::Any, _)
865 | (Direction::Egress, Direction::Egress)
866 | (Direction::Ingress, Direction::Ingress)
867 )
868}
869
870fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
871 match (earlier, later) {
872 (Destination::Any, _) => true,
873 (Destination::Group(eg), Destination::Group(lg)) => eg == lg,
874 (Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
875 _ => false,
877 }
878}
879
880fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
881 match (outer, inner) {
882 (IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
883 (IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
884 _ => false,
885 }
886}
887
888fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
889 if earlier.is_empty() {
890 return true; }
892 if later.is_empty() {
893 return false; }
895 later.iter().all(|p| earlier.contains(p))
896}
897
898fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
899 if earlier.is_empty() {
900 return true;
901 }
902 if later.is_empty() {
903 return false;
904 }
905 later.iter().all(|lp| {
906 earlier
907 .iter()
908 .any(|ep| ep.start <= lp.start && lp.end <= ep.end)
909 })
910}
911
912impl NetworkPolicy {
917 pub fn builder() -> NetworkPolicyBuilder {
919 NetworkPolicyBuilder::new()
920 }
921}
922
923#[cfg(test)]
928mod tests {
929 use super::*;
930
931 #[test]
934 fn empty_builder_yields_asymmetric_default() {
935 let p = NetworkPolicy::builder().build().unwrap();
936 assert!(matches!(p.default_egress, Action::Deny));
937 assert!(matches!(p.default_ingress, Action::Allow));
938 assert!(p.rules.is_empty());
939 }
940
941 #[test]
944 fn defaults_set_and_override() {
945 let p = NetworkPolicy::builder()
946 .default_deny()
947 .default_ingress(Action::Allow)
948 .build()
949 .unwrap();
950 assert!(matches!(p.default_egress, Action::Deny));
951 assert!(matches!(p.default_ingress, Action::Allow));
952 }
953
954 #[test]
957 fn egress_closure_commits_one_rule_per_shortcut() {
958 let p = NetworkPolicy::builder()
959 .egress(|e| e.tcp().port(443).allow_public().allow_private())
960 .build()
961 .unwrap();
962 assert_eq!(p.rules.len(), 2);
963 assert!(matches!(p.rules[0].direction, Direction::Egress));
964 assert!(matches!(p.rules[0].action, Action::Allow));
965 assert!(matches!(
966 p.rules[0].destination,
967 Destination::Group(DestinationGroup::Public)
968 ));
969 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
970 assert_eq!(p.rules[0].ports.len(), 1);
971 assert!(matches!(
972 p.rules[1].destination,
973 Destination::Group(DestinationGroup::Private)
974 ));
975 }
976
977 #[test]
979 fn allow_local_expands_to_three_groups() {
980 let p = NetworkPolicy::builder()
981 .egress(|e| e.allow_local())
982 .build()
983 .unwrap();
984 assert_eq!(p.rules.len(), 3);
985 let groups: Vec<_> = p
986 .rules
987 .iter()
988 .map(|r| match &r.destination {
989 Destination::Group(g) => *g,
990 other => panic!("unexpected destination {other:?}"),
991 })
992 .collect();
993 assert_eq!(
994 groups,
995 vec![
996 DestinationGroup::Loopback,
997 DestinationGroup::LinkLocal,
998 DestinationGroup::Host,
999 ]
1000 );
1001 }
1002
1003 #[test]
1006 fn explicit_ip_parses_at_build() {
1007 let p = NetworkPolicy::builder()
1008 .any(|a| a.deny().ip("198.51.100.5"))
1009 .build()
1010 .unwrap();
1011 assert_eq!(p.rules.len(), 1);
1012 assert!(matches!(p.rules[0].direction, Direction::Any));
1013 assert!(matches!(p.rules[0].action, Action::Deny));
1014 match &p.rules[0].destination {
1015 Destination::Cidr(net) => {
1016 assert_eq!(net.to_string(), "198.51.100.5/32");
1017 }
1018 other => panic!("expected Cidr, got {other:?}"),
1019 }
1020 }
1021
1022 #[test]
1025 fn invalid_ip_surfaces_at_build() {
1026 let result = NetworkPolicy::builder()
1027 .egress(|e| e.allow().ip("not-an-ip"))
1028 .build();
1029 match result {
1030 Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
1031 assert_eq!(raw, "not-an-ip");
1032 }
1033 other => panic!("expected InvalidIp, got {other:?}"),
1034 }
1035 }
1036
1037 #[test]
1039 fn domain_parses_to_canonical_form() {
1040 let p = NetworkPolicy::builder()
1041 .egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
1042 .build()
1043 .unwrap();
1044 match &p.rules[0].destination {
1045 Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
1046 other => panic!("expected Domain, got {other:?}"),
1047 }
1048 }
1049
1050 #[test]
1052 fn invalid_port_range_surfaces_at_build() {
1053 let result = NetworkPolicy::builder()
1054 .egress(|e| e.tcp().port_range(443, 80).allow_public())
1055 .build();
1056 match result {
1057 Err(BuildError::InvalidPortRange {
1058 lo: 443, hi: 80, ..
1059 }) => {}
1060 other => panic!("expected InvalidPortRange, got {other:?}"),
1061 }
1062 }
1063
1064 #[test]
1066 fn missing_direction_surfaces_at_build() {
1067 let result = NetworkPolicy::builder()
1068 .rule(|r| r.tcp().port(443).allow_public())
1069 .build();
1070 match result {
1071 Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
1072 other => panic!("expected DirectionNotSet, got {other:?}"),
1073 }
1074 }
1075
1076 #[test]
1078 fn icmp_in_ingress_rejected_at_build() {
1079 let result = NetworkPolicy::builder()
1080 .ingress(|i| i.icmpv4().allow_public())
1081 .build();
1082 match result {
1083 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1084 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1085 }
1086 }
1087
1088 #[test]
1090 fn icmp_in_any_direction_rejected_at_build() {
1091 let result = NetworkPolicy::builder()
1092 .any(|a| a.icmpv6().allow_public())
1093 .build();
1094 match result {
1095 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1096 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1097 }
1098 }
1099
1100 #[test]
1102 fn duplicate_protocols_dedupe() {
1103 let p = NetworkPolicy::builder()
1104 .egress(|e| e.tcp().tcp().udp().tcp().allow_public())
1105 .build()
1106 .unwrap();
1107 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
1108 }
1109
1110 #[test]
1113 fn explicit_group_uses_typed_argument() {
1114 let p = NetworkPolicy::builder()
1115 .egress(|e| e.allow().group(DestinationGroup::Multicast))
1116 .build()
1117 .unwrap();
1118 assert!(matches!(
1119 p.rules[0].destination,
1120 Destination::Group(DestinationGroup::Multicast)
1121 ));
1122 }
1123
1124 #[test]
1128 fn chain_form_compiles_without_explicit_return() {
1129 let _ = NetworkPolicy::builder()
1130 .rule(|r| r.egress().tcp().allow_public())
1131 .build()
1132 .unwrap();
1133 }
1134
1135 #[test]
1140 fn shadowed_rule_builds_and_is_detected() {
1141 let broader = Rule {
1142 direction: Direction::Egress,
1143 destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
1144 protocols: vec![],
1145 ports: vec![],
1146 action: Action::Allow,
1147 };
1148 let narrower = Rule {
1149 direction: Direction::Egress,
1150 destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
1151 protocols: vec![],
1152 ports: vec![],
1153 action: Action::Allow,
1154 };
1155 assert!(
1156 shadows(&broader, &narrower),
1157 "10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
1158 );
1159 assert!(
1160 !shadows(&narrower, &broader),
1161 "10.0.0.5/32 should NOT shadow 10.0.0.0/8"
1162 );
1163
1164 let _ = NetworkPolicy::builder()
1167 .egress(|e| e.allow().cidr("10.0.0.0/8"))
1168 .egress(|e| e.allow().cidr("10.0.0.5/32"))
1169 .build()
1170 .unwrap();
1171 }
1172
1173 #[test]
1176 fn direction_cover_relations() {
1177 use Direction::*;
1178 assert!(direction_covers(Any, Egress));
1179 assert!(direction_covers(Any, Ingress));
1180 assert!(direction_covers(Any, Any));
1181 assert!(direction_covers(Egress, Egress));
1182 assert!(!direction_covers(Egress, Ingress));
1183 assert!(!direction_covers(Egress, Any)); assert!(direction_covers(Ingress, Ingress));
1185 assert!(!direction_covers(Ingress, Egress));
1186 assert!(!direction_covers(Ingress, Any));
1187 }
1188
1189 #[test]
1196 fn deny_domains_produces_one_rule_per_name() {
1197 let p = NetworkPolicy::builder()
1198 .default_allow()
1199 .egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
1200 .build()
1201 .unwrap();
1202 assert_eq!(p.rules.len(), 2);
1203 for rule in &p.rules {
1204 assert_eq!(rule.action, Action::Deny);
1205 assert_eq!(rule.direction, Direction::Egress);
1206 assert!(rule.protocols.is_empty(), "no protocol filter");
1207 assert!(rule.ports.is_empty(), "no port filter");
1208 }
1209 assert!(matches!(
1210 &p.rules[0].destination,
1211 Destination::Domain(d) if d.as_str() == "evil.com",
1212 ));
1213 assert!(matches!(
1214 &p.rules[1].destination,
1215 Destination::Domain(d) if d.as_str() == "tracker.example",
1216 ));
1217 }
1218
1219 #[test]
1222 fn deny_domain_suffixes_produces_one_rule_per_suffix() {
1223 let p = NetworkPolicy::builder()
1224 .default_allow()
1225 .egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
1226 .build()
1227 .unwrap();
1228 assert_eq!(p.rules.len(), 2);
1229 assert!(matches!(
1230 &p.rules[0].destination,
1231 Destination::DomainSuffix(d) if d.as_str() == "ads.example",
1232 ));
1233 assert!(matches!(
1234 &p.rules[1].destination,
1235 Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
1236 ));
1237 }
1238
1239 #[test]
1242 fn deny_domains_inherits_protocol_and_port_filter() {
1243 let p = NetworkPolicy::builder()
1244 .default_allow()
1245 .egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
1246 .build()
1247 .unwrap();
1248 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
1249 assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
1250 }
1251
1252 #[test]
1255 fn allow_domains_produces_allow_rules() {
1256 let p = NetworkPolicy::builder()
1257 .default_deny()
1258 .egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
1259 .build()
1260 .unwrap();
1261 assert_eq!(p.rules.len(), 2);
1262 for rule in &p.rules {
1263 assert_eq!(rule.action, Action::Allow);
1264 }
1265 }
1266
1267 #[test]
1269 fn deny_domains_empty_input_is_noop() {
1270 let p = NetworkPolicy::builder()
1271 .default_allow()
1272 .egress(|e| e.deny_domains(Vec::<&str>::new()))
1273 .build()
1274 .unwrap();
1275 assert!(p.rules.is_empty());
1276 }
1277
1278 #[test]
1282 fn deny_domains_invalid_input_surfaces_at_build() {
1283 let result = NetworkPolicy::builder()
1284 .default_allow()
1285 .egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
1286 .build();
1287 match result {
1288 Err(BuildError::InvalidDomain {
1289 raw, rule_index, ..
1290 }) => {
1291 assert_eq!(raw, "not a domain!");
1292 assert_eq!(rule_index, 1);
1295 }
1296 other => panic!("expected InvalidDomain, got {other:?}"),
1297 }
1298 }
1299}