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("intercept CA config is incomplete; set both cert_path and key_path")]
98 IncompleteInterceptCaConfig,
99
100 #[error("rule #{rule_index}: invalid domain `{raw}`: {source}")]
103 InvalidDomain {
104 rule_index: usize,
105 raw: String,
106 #[source]
107 source: DomainNameError,
108 },
109
110 #[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
112 InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
113
114 #[error(
118 "rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
119 )]
120 IngressDoesNotSupportIcmp { rule_index: usize },
121
122 #[error("{source}")]
124 InvalidSecretConfig {
125 #[from]
127 source: SecretConfigError,
128 },
129
130 #[error("{direction} rate limiter: {source}")]
132 InvalidRateLimitConfig {
133 direction: NetworkRateLimitDirection,
135 #[source]
137 source: RateLimitConfigError,
138 },
139
140 #[error("rate limiter must configure at least one of egress or ingress")]
142 EmptyNetworkRateLimiter,
143
144 #[error("{direction} rate limiter: {bucket}_burst requires the {bucket} bucket")]
146 RateLimitBurstWithoutBucket {
147 direction: NetworkRateLimitDirection,
149 bucket: &'static str,
151 },
152
153 #[error("{direction} rate limiter: {bucket} refill interval must be at least one millisecond")]
155 RateLimitRefillTooShort {
156 direction: NetworkRateLimitDirection,
158 bucket: &'static str,
160 },
161
162 #[error(
164 "{direction} rate limiter: {bucket} refill interval must be a whole number of milliseconds"
165 )]
166 RateLimitRefillPrecision {
167 direction: NetworkRateLimitDirection,
169 bucket: &'static str,
171 },
172
173 #[error("{direction} rate limiter: {bucket} refill interval overflows u64 milliseconds")]
175 RateLimitRefillTooLong {
176 direction: NetworkRateLimitDirection,
178 bucket: &'static str,
180 },
181}
182
183#[derive(Debug, Default)]
191pub struct NetworkPolicyBuilder {
192 default_egress: Option<Action>,
193 default_ingress: Option<Action>,
194 pending_rules: Vec<PendingRule>,
195 errors: Vec<BuildError>,
196}
197
198impl NetworkPolicyBuilder {
199 pub fn new() -> Self {
201 Self::default()
202 }
203
204 pub fn default_allow(mut self) -> Self {
206 self.default_egress = Some(Action::Allow);
207 self.default_ingress = Some(Action::Allow);
208 self
209 }
210
211 pub fn default_deny(mut self) -> Self {
213 self.default_egress = Some(Action::Deny);
214 self.default_ingress = Some(Action::Deny);
215 self
216 }
217
218 pub fn default_egress(mut self, action: Action) -> Self {
220 self.default_egress = Some(action);
221 self
222 }
223
224 pub fn default_ingress(mut self, action: Action) -> Self {
226 self.default_ingress = Some(action);
227 self
228 }
229
230 pub fn rule<F>(self, f: F) -> Self
233 where
234 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
235 {
236 self.with_rule_builder(None, f)
237 }
238
239 pub fn egress<F>(self, f: F) -> Self
241 where
242 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
243 {
244 self.with_rule_builder(Some(Direction::Egress), f)
245 }
246
247 pub fn ingress<F>(self, f: F) -> Self
249 where
250 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
251 {
252 self.with_rule_builder(Some(Direction::Ingress), f)
253 }
254
255 pub fn any<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::Any), f)
262 }
263
264 fn with_rule_builder<F>(mut self, initial_direction: Option<Direction>, f: F) -> Self
265 where
266 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
267 {
268 let mut rb = RuleBuilder {
269 direction: initial_direction,
270 protocols: Vec::new(),
271 ports: Vec::new(),
272 pending_rules: Vec::new(),
273 errors: Vec::new(),
274 };
275 let _ = f(&mut rb);
276 self.pending_rules.append(&mut rb.pending_rules);
277 self.errors.append(&mut rb.errors);
278 self
279 }
280
281 pub fn build(self) -> Result<NetworkPolicy, BuildError> {
290 if let Some(err) = self.errors.into_iter().next() {
291 return Err(err);
292 }
293
294 let mut rules = Vec::with_capacity(self.pending_rules.len());
295 for (idx, pending) in self.pending_rules.into_iter().enumerate() {
296 let direction = pending
297 .direction
298 .ok_or(BuildError::DirectionNotSet { rule_index: idx })?;
299 let destination = pending.destination.parse(idx)?;
300
301 if matches!(direction, Direction::Ingress | Direction::Any)
302 && pending
303 .protocols
304 .iter()
305 .any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
306 {
307 return Err(BuildError::IngressDoesNotSupportIcmp { rule_index: idx });
308 }
309
310 rules.push(Rule {
311 direction,
312 destination,
313 protocols: pending.protocols,
314 ports: pending.ports,
315 action: pending.action,
316 });
317 }
318
319 warn_about_shadows(&rules);
320
321 Ok(NetworkPolicy {
322 default_egress: self.default_egress.unwrap_or_else(default_egress_default),
323 default_ingress: self.default_ingress.unwrap_or_else(default_ingress_default),
324 rules,
325 })
326 }
327}
328
329fn default_egress_default() -> Action {
333 Action::Deny
334}
335
336fn default_ingress_default() -> Action {
340 Action::Allow
341}
342
343#[derive(Debug)]
353pub struct RuleBuilder {
354 direction: Option<Direction>,
355 protocols: Vec<Protocol>,
356 ports: Vec<PortRange>,
357 pending_rules: Vec<PendingRule>,
358 errors: Vec<BuildError>,
359}
360
361impl RuleBuilder {
362 pub fn egress(&mut self) -> &mut Self {
366 self.direction = Some(Direction::Egress);
367 self
368 }
369
370 pub fn ingress(&mut self) -> &mut Self {
372 self.direction = Some(Direction::Ingress);
373 self
374 }
375
376 pub fn any(&mut self) -> &mut Self {
379 self.direction = Some(Direction::Any);
380 self
381 }
382
383 pub fn tcp(&mut self) -> &mut Self {
387 self.add_protocol(Protocol::Tcp)
388 }
389
390 pub fn udp(&mut self) -> &mut Self {
392 self.add_protocol(Protocol::Udp)
393 }
394
395 pub fn icmpv4(&mut self) -> &mut Self {
399 self.add_protocol(Protocol::Icmpv4)
400 }
401
402 pub fn icmpv6(&mut self) -> &mut Self {
404 self.add_protocol(Protocol::Icmpv6)
405 }
406
407 fn add_protocol(&mut self, p: Protocol) -> &mut Self {
408 if !self.protocols.contains(&p) {
409 self.protocols.push(p);
410 }
411 self
412 }
413
414 pub fn port(&mut self, port: u16) -> &mut Self {
418 let pr = PortRange::single(port);
419 if !self.ports.contains(&pr) {
420 self.ports.push(pr);
421 }
422 self
423 }
424
425 pub fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self {
428 if lo > hi {
429 self.errors.push(BuildError::InvalidPortRange {
430 rule_index: self.pending_rules.len(),
431 lo,
432 hi,
433 });
434 return self;
435 }
436 let pr = PortRange::range(lo, hi);
437 if !self.ports.contains(&pr) {
438 self.ports.push(pr);
439 }
440 self
441 }
442
443 pub fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self {
446 for p in ports {
447 self.port(p);
448 }
449 self
450 }
451
452 pub fn allow_public(&mut self) -> &mut Self {
456 self.commit_group(Action::Allow, DestinationGroup::Public)
457 }
458
459 pub fn deny_public(&mut self) -> &mut Self {
461 self.commit_group(Action::Deny, DestinationGroup::Public)
462 }
463
464 pub fn allow_private(&mut self) -> &mut Self {
466 self.commit_group(Action::Allow, DestinationGroup::Private)
467 }
468
469 pub fn deny_private(&mut self) -> &mut Self {
471 self.commit_group(Action::Deny, DestinationGroup::Private)
472 }
473
474 pub fn allow_loopback(&mut self) -> &mut Self {
483 self.commit_group(Action::Allow, DestinationGroup::Loopback)
484 }
485
486 pub fn deny_loopback(&mut self) -> &mut Self {
494 self.commit_group(Action::Deny, DestinationGroup::Loopback)
495 }
496
497 pub fn allow_link_local(&mut self) -> &mut Self {
501 self.commit_group(Action::Allow, DestinationGroup::LinkLocal)
502 }
503
504 pub fn deny_link_local(&mut self) -> &mut Self {
506 self.commit_group(Action::Deny, DestinationGroup::LinkLocal)
507 }
508
509 pub fn allow_meta(&mut self) -> &mut Self {
512 self.commit_group(Action::Allow, DestinationGroup::Metadata)
513 }
514
515 pub fn deny_meta(&mut self) -> &mut Self {
517 self.commit_group(Action::Deny, DestinationGroup::Metadata)
518 }
519
520 pub fn allow_multicast(&mut self) -> &mut Self {
522 self.commit_group(Action::Allow, DestinationGroup::Multicast)
523 }
524
525 pub fn deny_multicast(&mut self) -> &mut Self {
527 self.commit_group(Action::Deny, DestinationGroup::Multicast)
528 }
529
530 pub fn allow_host(&mut self) -> &mut Self {
535 self.commit_group(Action::Allow, DestinationGroup::Host)
536 }
537
538 pub fn deny_host(&mut self) -> &mut Self {
540 self.commit_group(Action::Deny, DestinationGroup::Host)
541 }
542
543 pub fn allow_local(&mut self) -> &mut Self {
556 self.allow_loopback();
557 self.allow_link_local();
558 self.allow_host();
559 self
560 }
561
562 pub fn deny_local(&mut self) -> &mut Self {
565 self.deny_loopback();
566 self.deny_link_local();
567 self.deny_host();
568 self
569 }
570
571 pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
575 where
576 I: IntoIterator<Item = S>,
577 S: Into<String>,
578 {
579 for name in names {
580 self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
581 }
582 self
583 }
584
585 pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
587 where
588 I: IntoIterator<Item = S>,
589 S: Into<String>,
590 {
591 for name in names {
592 self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
593 }
594 self
595 }
596
597 pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
599 where
600 I: IntoIterator<Item = S>,
601 S: Into<String>,
602 {
603 for suffix in suffixes {
604 self.commit_rule(
605 Action::Allow,
606 PendingDestination::DomainSuffix(suffix.into()),
607 );
608 }
609 self
610 }
611
612 pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
614 where
615 I: IntoIterator<Item = S>,
616 S: Into<String>,
617 {
618 for suffix in suffixes {
619 self.commit_rule(
620 Action::Deny,
621 PendingDestination::DomainSuffix(suffix.into()),
622 );
623 }
624 self
625 }
626
627 pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
634 RuleDestinationBuilder {
635 rule_builder: self,
636 action: Action::Allow,
637 }
638 }
639
640 pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
642 RuleDestinationBuilder {
643 rule_builder: self,
644 action: Action::Deny,
645 }
646 }
647
648 fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
651 self.commit_rule(
652 action,
653 PendingDestination::Resolved(Destination::Group(group)),
654 );
655 self
656 }
657
658 fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
659 self.pending_rules.push(PendingRule {
660 direction: self.direction,
661 destination,
662 protocols: self.protocols.clone(),
663 ports: self.ports.clone(),
664 action,
665 });
666 }
667}
668
669#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
679pub struct RuleDestinationBuilder<'a> {
680 rule_builder: &'a mut RuleBuilder,
681 action: Action,
682}
683
684impl<'a> RuleDestinationBuilder<'a> {
685 pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
689 self.rule_builder
690 .commit_rule(self.action, PendingDestination::Ip(ip.into()));
691 self.rule_builder
692 }
693
694 pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
696 self.rule_builder
697 .commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
698 self.rule_builder
699 }
700
701 pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
705 self.rule_builder
706 .commit_rule(self.action, PendingDestination::Domain(domain.into()));
707 self.rule_builder
708 }
709
710 pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
713 self.rule_builder
714 .commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
715 self.rule_builder
716 }
717
718 pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
720 self.rule_builder.commit_rule(
721 self.action,
722 PendingDestination::Resolved(Destination::Group(group)),
723 );
724 self.rule_builder
725 }
726
727 pub fn any(self) -> &'a mut RuleBuilder {
729 self.rule_builder
730 .commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
731 self.rule_builder
732 }
733}
734
735#[derive(Debug, Clone)]
740struct PendingRule {
741 direction: Option<Direction>,
742 destination: PendingDestination,
743 protocols: Vec<Protocol>,
744 ports: Vec<PortRange>,
745 action: Action,
746}
747
748#[derive(Debug, Clone)]
749enum PendingDestination {
750 Resolved(Destination),
752 Ip(String),
753 Cidr(String),
754 Domain(String),
755 DomainSuffix(String),
756}
757
758impl PendingDestination {
759 fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
760 match self {
761 PendingDestination::Resolved(d) => Ok(d.clone()),
762 PendingDestination::Ip(raw) => {
763 let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
764 rule_index: idx,
765 raw: raw.clone(),
766 })?;
767 let prefix = if ip.is_ipv4() { 32 } else { 128 };
770 let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
771 rule_index: idx,
772 raw: raw.clone(),
773 })?;
774 Ok(Destination::Cidr(net))
775 }
776 PendingDestination::Cidr(raw) => {
777 let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
778 rule_index: idx,
779 raw: raw.clone(),
780 })?;
781 Ok(Destination::Cidr(net))
782 }
783 PendingDestination::Domain(raw) => {
784 let name =
785 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
786 rule_index: idx,
787 raw: raw.clone(),
788 source,
789 })?;
790 Ok(Destination::Domain(name))
791 }
792 PendingDestination::DomainSuffix(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 let name = name
800 .try_into_suffix()
801 .map_err(|source| BuildError::InvalidDomain {
802 rule_index: idx,
803 raw: raw.clone(),
804 source,
805 })?;
806 Ok(Destination::DomainSuffix(name))
807 }
808 }
809 }
810}
811
812fn warn_about_shadows(rules: &[Rule]) {
824 for (i, later) in rules.iter().enumerate() {
825 for (j, earlier) in rules.iter().take(i).enumerate() {
826 if shadows(earlier, later) {
827 tracing::warn!(
828 shadowed_index = i,
829 shadowed_by = j,
830 "rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
831 later.direction,
832 later.action,
833 later.destination,
834 earlier.direction,
835 earlier.action,
836 earlier.destination,
837 );
838 }
839 }
840 }
841}
842
843fn shadows(earlier: &Rule, later: &Rule) -> bool {
846 direction_covers(earlier.direction, later.direction)
847 && destination_covers(&earlier.destination, &later.destination)
848 && protocol_set_covers(&earlier.protocols, &later.protocols)
849 && port_set_covers(&earlier.ports, &later.ports)
850}
851
852fn direction_covers(earlier: Direction, later: Direction) -> bool {
853 matches!(
854 (earlier, later),
855 (Direction::Any, _)
856 | (Direction::Egress, Direction::Egress)
857 | (Direction::Ingress, Direction::Ingress)
858 )
859}
860
861fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
862 match (earlier, later) {
863 (Destination::Any, _) => true,
864 (Destination::Group(eg), Destination::Group(lg)) => eg == lg,
865 (Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
866 _ => false,
868 }
869}
870
871fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
872 match (outer, inner) {
873 (IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
874 (IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
875 _ => false,
876 }
877}
878
879fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
880 if earlier.is_empty() {
881 return true; }
883 if later.is_empty() {
884 return false; }
886 later.iter().all(|p| earlier.contains(p))
887}
888
889fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
890 if earlier.is_empty() {
891 return true;
892 }
893 if later.is_empty() {
894 return false;
895 }
896 later.iter().all(|lp| {
897 earlier
898 .iter()
899 .any(|ep| ep.start <= lp.start && lp.end <= ep.end)
900 })
901}
902
903impl NetworkPolicy {
908 pub fn builder() -> NetworkPolicyBuilder {
910 NetworkPolicyBuilder::new()
911 }
912}
913
914#[cfg(test)]
919mod tests {
920 use super::*;
921
922 #[test]
925 fn empty_builder_yields_asymmetric_default() {
926 let p = NetworkPolicy::builder().build().unwrap();
927 assert!(matches!(p.default_egress, Action::Deny));
928 assert!(matches!(p.default_ingress, Action::Allow));
929 assert!(p.rules.is_empty());
930 }
931
932 #[test]
935 fn defaults_set_and_override() {
936 let p = NetworkPolicy::builder()
937 .default_deny()
938 .default_ingress(Action::Allow)
939 .build()
940 .unwrap();
941 assert!(matches!(p.default_egress, Action::Deny));
942 assert!(matches!(p.default_ingress, Action::Allow));
943 }
944
945 #[test]
948 fn egress_closure_commits_one_rule_per_shortcut() {
949 let p = NetworkPolicy::builder()
950 .egress(|e| e.tcp().port(443).allow_public().allow_private())
951 .build()
952 .unwrap();
953 assert_eq!(p.rules.len(), 2);
954 assert!(matches!(p.rules[0].direction, Direction::Egress));
955 assert!(matches!(p.rules[0].action, Action::Allow));
956 assert!(matches!(
957 p.rules[0].destination,
958 Destination::Group(DestinationGroup::Public)
959 ));
960 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
961 assert_eq!(p.rules[0].ports.len(), 1);
962 assert!(matches!(
963 p.rules[1].destination,
964 Destination::Group(DestinationGroup::Private)
965 ));
966 }
967
968 #[test]
970 fn allow_local_expands_to_three_groups() {
971 let p = NetworkPolicy::builder()
972 .egress(|e| e.allow_local())
973 .build()
974 .unwrap();
975 assert_eq!(p.rules.len(), 3);
976 let groups: Vec<_> = p
977 .rules
978 .iter()
979 .map(|r| match &r.destination {
980 Destination::Group(g) => *g,
981 other => panic!("unexpected destination {other:?}"),
982 })
983 .collect();
984 assert_eq!(
985 groups,
986 vec![
987 DestinationGroup::Loopback,
988 DestinationGroup::LinkLocal,
989 DestinationGroup::Host,
990 ]
991 );
992 }
993
994 #[test]
997 fn explicit_ip_parses_at_build() {
998 let p = NetworkPolicy::builder()
999 .any(|a| a.deny().ip("198.51.100.5"))
1000 .build()
1001 .unwrap();
1002 assert_eq!(p.rules.len(), 1);
1003 assert!(matches!(p.rules[0].direction, Direction::Any));
1004 assert!(matches!(p.rules[0].action, Action::Deny));
1005 match &p.rules[0].destination {
1006 Destination::Cidr(net) => {
1007 assert_eq!(net.to_string(), "198.51.100.5/32");
1008 }
1009 other => panic!("expected Cidr, got {other:?}"),
1010 }
1011 }
1012
1013 #[test]
1016 fn invalid_ip_surfaces_at_build() {
1017 let result = NetworkPolicy::builder()
1018 .egress(|e| e.allow().ip("not-an-ip"))
1019 .build();
1020 match result {
1021 Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
1022 assert_eq!(raw, "not-an-ip");
1023 }
1024 other => panic!("expected InvalidIp, got {other:?}"),
1025 }
1026 }
1027
1028 #[test]
1030 fn domain_parses_to_canonical_form() {
1031 let p = NetworkPolicy::builder()
1032 .egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
1033 .build()
1034 .unwrap();
1035 match &p.rules[0].destination {
1036 Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
1037 other => panic!("expected Domain, got {other:?}"),
1038 }
1039 }
1040
1041 #[test]
1043 fn invalid_port_range_surfaces_at_build() {
1044 let result = NetworkPolicy::builder()
1045 .egress(|e| e.tcp().port_range(443, 80).allow_public())
1046 .build();
1047 match result {
1048 Err(BuildError::InvalidPortRange {
1049 lo: 443, hi: 80, ..
1050 }) => {}
1051 other => panic!("expected InvalidPortRange, got {other:?}"),
1052 }
1053 }
1054
1055 #[test]
1057 fn missing_direction_surfaces_at_build() {
1058 let result = NetworkPolicy::builder()
1059 .rule(|r| r.tcp().port(443).allow_public())
1060 .build();
1061 match result {
1062 Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
1063 other => panic!("expected DirectionNotSet, got {other:?}"),
1064 }
1065 }
1066
1067 #[test]
1069 fn icmp_in_ingress_rejected_at_build() {
1070 let result = NetworkPolicy::builder()
1071 .ingress(|i| i.icmpv4().allow_public())
1072 .build();
1073 match result {
1074 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1075 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1076 }
1077 }
1078
1079 #[test]
1081 fn icmp_in_any_direction_rejected_at_build() {
1082 let result = NetworkPolicy::builder()
1083 .any(|a| a.icmpv6().allow_public())
1084 .build();
1085 match result {
1086 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1087 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1088 }
1089 }
1090
1091 #[test]
1093 fn duplicate_protocols_dedupe() {
1094 let p = NetworkPolicy::builder()
1095 .egress(|e| e.tcp().tcp().udp().tcp().allow_public())
1096 .build()
1097 .unwrap();
1098 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
1099 }
1100
1101 #[test]
1104 fn explicit_group_uses_typed_argument() {
1105 let p = NetworkPolicy::builder()
1106 .egress(|e| e.allow().group(DestinationGroup::Multicast))
1107 .build()
1108 .unwrap();
1109 assert!(matches!(
1110 p.rules[0].destination,
1111 Destination::Group(DestinationGroup::Multicast)
1112 ));
1113 }
1114
1115 #[test]
1119 fn chain_form_compiles_without_explicit_return() {
1120 let _ = NetworkPolicy::builder()
1121 .rule(|r| r.egress().tcp().allow_public())
1122 .build()
1123 .unwrap();
1124 }
1125
1126 #[test]
1131 fn shadowed_rule_builds_and_is_detected() {
1132 let broader = Rule {
1133 direction: Direction::Egress,
1134 destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
1135 protocols: vec![],
1136 ports: vec![],
1137 action: Action::Allow,
1138 };
1139 let narrower = Rule {
1140 direction: Direction::Egress,
1141 destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
1142 protocols: vec![],
1143 ports: vec![],
1144 action: Action::Allow,
1145 };
1146 assert!(
1147 shadows(&broader, &narrower),
1148 "10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
1149 );
1150 assert!(
1151 !shadows(&narrower, &broader),
1152 "10.0.0.5/32 should NOT shadow 10.0.0.0/8"
1153 );
1154
1155 let _ = NetworkPolicy::builder()
1158 .egress(|e| e.allow().cidr("10.0.0.0/8"))
1159 .egress(|e| e.allow().cidr("10.0.0.5/32"))
1160 .build()
1161 .unwrap();
1162 }
1163
1164 #[test]
1167 fn direction_cover_relations() {
1168 use Direction::*;
1169 assert!(direction_covers(Any, Egress));
1170 assert!(direction_covers(Any, Ingress));
1171 assert!(direction_covers(Any, Any));
1172 assert!(direction_covers(Egress, Egress));
1173 assert!(!direction_covers(Egress, Ingress));
1174 assert!(!direction_covers(Egress, Any)); assert!(direction_covers(Ingress, Ingress));
1176 assert!(!direction_covers(Ingress, Egress));
1177 assert!(!direction_covers(Ingress, Any));
1178 }
1179
1180 #[test]
1187 fn deny_domains_produces_one_rule_per_name() {
1188 let p = NetworkPolicy::builder()
1189 .default_allow()
1190 .egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
1191 .build()
1192 .unwrap();
1193 assert_eq!(p.rules.len(), 2);
1194 for rule in &p.rules {
1195 assert_eq!(rule.action, Action::Deny);
1196 assert_eq!(rule.direction, Direction::Egress);
1197 assert!(rule.protocols.is_empty(), "no protocol filter");
1198 assert!(rule.ports.is_empty(), "no port filter");
1199 }
1200 assert!(matches!(
1201 &p.rules[0].destination,
1202 Destination::Domain(d) if d.as_str() == "evil.com",
1203 ));
1204 assert!(matches!(
1205 &p.rules[1].destination,
1206 Destination::Domain(d) if d.as_str() == "tracker.example",
1207 ));
1208 }
1209
1210 #[test]
1213 fn deny_domain_suffixes_produces_one_rule_per_suffix() {
1214 let p = NetworkPolicy::builder()
1215 .default_allow()
1216 .egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
1217 .build()
1218 .unwrap();
1219 assert_eq!(p.rules.len(), 2);
1220 assert!(matches!(
1221 &p.rules[0].destination,
1222 Destination::DomainSuffix(d) if d.as_str() == "ads.example",
1223 ));
1224 assert!(matches!(
1225 &p.rules[1].destination,
1226 Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
1227 ));
1228 }
1229
1230 #[test]
1233 fn deny_domains_inherits_protocol_and_port_filter() {
1234 let p = NetworkPolicy::builder()
1235 .default_allow()
1236 .egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
1237 .build()
1238 .unwrap();
1239 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
1240 assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
1241 }
1242
1243 #[test]
1246 fn allow_domains_produces_allow_rules() {
1247 let p = NetworkPolicy::builder()
1248 .default_deny()
1249 .egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
1250 .build()
1251 .unwrap();
1252 assert_eq!(p.rules.len(), 2);
1253 for rule in &p.rules {
1254 assert_eq!(rule.action, Action::Allow);
1255 }
1256 }
1257
1258 #[test]
1260 fn deny_domains_empty_input_is_noop() {
1261 let p = NetworkPolicy::builder()
1262 .default_allow()
1263 .egress(|e| e.deny_domains(Vec::<&str>::new()))
1264 .build()
1265 .unwrap();
1266 assert!(p.rules.is_empty());
1267 }
1268
1269 #[test]
1273 fn deny_domains_invalid_input_surfaces_at_build() {
1274 let result = NetworkPolicy::builder()
1275 .default_allow()
1276 .egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
1277 .build();
1278 match result {
1279 Err(BuildError::InvalidDomain {
1280 raw, rule_index, ..
1281 }) => {
1282 assert_eq!(raw, "not a domain!");
1283 assert_eq!(rule_index, 1);
1286 }
1287 other => panic!("expected InvalidDomain, got {other:?}"),
1288 }
1289 }
1290}