1use std::str::FromStr;
33
34use ipnetwork::IpNetwork;
35use microsandbox_types::{NetworkRateLimitDirection, RateLimitConfigError};
36
37use crate::config::InvalidTcpAcceptQueueSize;
38use crate::secrets::config::SecretConfigError;
39
40use super::{
41 Action, Destination, DestinationGroup, Direction, DomainName, DomainNameError, NetworkPolicy,
42 PortRange, Protocol, Rule,
43};
44
45#[derive(Debug, Clone, thiserror::Error)]
59pub enum BuildError {
60 #[error(
62 "rule #{rule_index}: direction not set; call .egress(), .ingress(), or .any() before the rule-adder"
63 )]
64 DirectionNotSet { rule_index: usize },
65
66 #[error(
69 "rule #{rule_index}: destination not set; call .ip(), .cidr(), .domain(), .domain_suffix(), .group(), or .any() on the rule-destination builder"
70 )]
71 MissingDestination { rule_index: usize },
72
73 #[error("rule #{rule_index}: invalid IP address `{raw}`")]
76 InvalidIp { rule_index: usize, raw: String },
77
78 #[error("rule #{rule_index}: invalid CIDR `{raw}`")]
80 InvalidCidr { rule_index: usize, raw: String },
81
82 #[error("invalid IPv4 pool `{raw}`: prefix must be /30 or shorter")]
84 InvalidIpv4Pool { raw: String },
85
86 #[error("invalid IPv6 pool `{raw}`: prefix must be /64 or shorter")]
88 InvalidIpv6Pool { raw: String },
89
90 #[error("{source}")]
92 InvalidTcpAcceptQueueSize {
93 #[from]
95 source: InvalidTcpAcceptQueueSize,
96 },
97
98 #[error("invalid NAT64 prefix `{raw}`: prefix must be IPv6 /96")]
100 InvalidNat64Prefix {
101 raw: String,
103 },
104
105 #[error("invalid outbound proxy: {reason}")]
107 InvalidOutboundProxy {
108 reason: String,
110 },
111
112 #[error("intercept CA config is incomplete; set both cert_path and key_path")]
114 IncompleteInterceptCaConfig,
115
116 #[error("rule #{rule_index}: invalid domain `{raw}`: {source}")]
119 InvalidDomain {
120 rule_index: usize,
121 raw: String,
122 #[source]
123 source: DomainNameError,
124 },
125
126 #[error("rule #{rule_index}: invalid port range {lo}..{hi}; lo must be <= hi")]
128 InvalidPortRange { rule_index: usize, lo: u16, hi: u16 },
129
130 #[error(
134 "rule #{rule_index}: ICMP protocols are egress-only; ingress and any-direction rules cannot include icmpv4 or icmpv6"
135 )]
136 IngressDoesNotSupportIcmp { rule_index: usize },
137
138 #[error("{source}")]
140 InvalidSecretConfig {
141 #[from]
143 source: SecretConfigError,
144 },
145
146 #[error("{direction} rate limiter: {source}")]
148 InvalidRateLimitConfig {
149 direction: NetworkRateLimitDirection,
151 #[source]
153 source: RateLimitConfigError,
154 },
155
156 #[error("rate limiter must configure at least one of egress or ingress")]
158 EmptyNetworkRateLimiter,
159
160 #[error("{direction} rate limiter: {bucket}_burst requires the {bucket} bucket")]
162 RateLimitBurstWithoutBucket {
163 direction: NetworkRateLimitDirection,
165 bucket: &'static str,
167 },
168
169 #[error("{direction} rate limiter: {bucket} refill interval must be at least one millisecond")]
171 RateLimitRefillTooShort {
172 direction: NetworkRateLimitDirection,
174 bucket: &'static str,
176 },
177
178 #[error(
180 "{direction} rate limiter: {bucket} refill interval must be a whole number of milliseconds"
181 )]
182 RateLimitRefillPrecision {
183 direction: NetworkRateLimitDirection,
185 bucket: &'static str,
187 },
188
189 #[error("{direction} rate limiter: {bucket} refill interval overflows u64 milliseconds")]
191 RateLimitRefillTooLong {
192 direction: NetworkRateLimitDirection,
194 bucket: &'static str,
196 },
197}
198
199#[derive(Debug, Default)]
207pub struct NetworkPolicyBuilder {
208 default_egress: Option<Action>,
209 default_ingress: Option<Action>,
210 pending_rules: Vec<PendingRule>,
211 errors: Vec<BuildError>,
212}
213
214impl NetworkPolicyBuilder {
215 pub fn new() -> Self {
217 Self::default()
218 }
219
220 pub fn default_allow(mut self) -> Self {
222 self.default_egress = Some(Action::Allow);
223 self.default_ingress = Some(Action::Allow);
224 self
225 }
226
227 pub fn default_deny(mut self) -> Self {
229 self.default_egress = Some(Action::Deny);
230 self.default_ingress = Some(Action::Deny);
231 self
232 }
233
234 pub fn default_egress(mut self, action: Action) -> Self {
236 self.default_egress = Some(action);
237 self
238 }
239
240 pub fn default_ingress(mut self, action: Action) -> Self {
242 self.default_ingress = Some(action);
243 self
244 }
245
246 pub fn rule<F>(self, f: F) -> Self
249 where
250 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
251 {
252 self.with_rule_builder(None, f)
253 }
254
255 pub fn egress<F>(self, f: F) -> Self
257 where
258 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
259 {
260 self.with_rule_builder(Some(Direction::Egress), f)
261 }
262
263 pub fn ingress<F>(self, f: F) -> Self
265 where
266 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
267 {
268 self.with_rule_builder(Some(Direction::Ingress), f)
269 }
270
271 pub fn any<F>(self, f: F) -> Self
274 where
275 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
276 {
277 self.with_rule_builder(Some(Direction::Any), f)
278 }
279
280 fn with_rule_builder<F>(mut self, initial_direction: Option<Direction>, f: F) -> Self
281 where
282 F: for<'a> FnOnce(&'a mut RuleBuilder) -> &'a mut RuleBuilder,
283 {
284 let mut rb = RuleBuilder {
285 direction: initial_direction,
286 protocols: Vec::new(),
287 ports: Vec::new(),
288 pending_rules: Vec::new(),
289 errors: Vec::new(),
290 };
291 let _ = f(&mut rb);
292 self.pending_rules.append(&mut rb.pending_rules);
293 self.errors.append(&mut rb.errors);
294 self
295 }
296
297 pub fn build(self) -> Result<NetworkPolicy, BuildError> {
306 if let Some(err) = self.errors.into_iter().next() {
307 return Err(err);
308 }
309
310 let mut rules = Vec::with_capacity(self.pending_rules.len());
311 for (idx, pending) in self.pending_rules.into_iter().enumerate() {
312 let direction = pending
313 .direction
314 .ok_or(BuildError::DirectionNotSet { rule_index: idx })?;
315 let destination = pending.destination.parse(idx)?;
316
317 if matches!(direction, Direction::Ingress | Direction::Any)
318 && pending
319 .protocols
320 .iter()
321 .any(|p| matches!(p, Protocol::Icmpv4 | Protocol::Icmpv6))
322 {
323 return Err(BuildError::IngressDoesNotSupportIcmp { rule_index: idx });
324 }
325
326 rules.push(Rule {
327 direction,
328 destination,
329 protocols: pending.protocols,
330 ports: pending.ports,
331 action: pending.action,
332 });
333 }
334
335 warn_about_shadows(&rules);
336
337 Ok(NetworkPolicy {
338 default_egress: self.default_egress.unwrap_or_else(default_egress_default),
339 default_ingress: self.default_ingress.unwrap_or_else(default_ingress_default),
340 rules,
341 })
342 }
343}
344
345fn default_egress_default() -> Action {
349 Action::Deny
350}
351
352fn default_ingress_default() -> Action {
356 Action::Allow
357}
358
359#[derive(Debug)]
369pub struct RuleBuilder {
370 direction: Option<Direction>,
371 protocols: Vec<Protocol>,
372 ports: Vec<PortRange>,
373 pending_rules: Vec<PendingRule>,
374 errors: Vec<BuildError>,
375}
376
377impl RuleBuilder {
378 pub fn egress(&mut self) -> &mut Self {
382 self.direction = Some(Direction::Egress);
383 self
384 }
385
386 pub fn ingress(&mut self) -> &mut Self {
388 self.direction = Some(Direction::Ingress);
389 self
390 }
391
392 pub fn any(&mut self) -> &mut Self {
395 self.direction = Some(Direction::Any);
396 self
397 }
398
399 pub fn tcp(&mut self) -> &mut Self {
403 self.add_protocol(Protocol::Tcp)
404 }
405
406 pub fn udp(&mut self) -> &mut Self {
408 self.add_protocol(Protocol::Udp)
409 }
410
411 pub fn icmpv4(&mut self) -> &mut Self {
415 self.add_protocol(Protocol::Icmpv4)
416 }
417
418 pub fn icmpv6(&mut self) -> &mut Self {
420 self.add_protocol(Protocol::Icmpv6)
421 }
422
423 fn add_protocol(&mut self, p: Protocol) -> &mut Self {
424 if !self.protocols.contains(&p) {
425 self.protocols.push(p);
426 }
427 self
428 }
429
430 pub fn port(&mut self, port: u16) -> &mut Self {
434 let pr = PortRange::single(port);
435 if !self.ports.contains(&pr) {
436 self.ports.push(pr);
437 }
438 self
439 }
440
441 pub fn port_range(&mut self, lo: u16, hi: u16) -> &mut Self {
444 if lo > hi {
445 self.errors.push(BuildError::InvalidPortRange {
446 rule_index: self.pending_rules.len(),
447 lo,
448 hi,
449 });
450 return self;
451 }
452 let pr = PortRange::range(lo, hi);
453 if !self.ports.contains(&pr) {
454 self.ports.push(pr);
455 }
456 self
457 }
458
459 pub fn ports<I: IntoIterator<Item = u16>>(&mut self, ports: I) -> &mut Self {
462 for p in ports {
463 self.port(p);
464 }
465 self
466 }
467
468 pub fn allow_public(&mut self) -> &mut Self {
472 self.commit_group(Action::Allow, DestinationGroup::Public)
473 }
474
475 pub fn deny_public(&mut self) -> &mut Self {
477 self.commit_group(Action::Deny, DestinationGroup::Public)
478 }
479
480 pub fn allow_private(&mut self) -> &mut Self {
482 self.commit_group(Action::Allow, DestinationGroup::Private)
483 }
484
485 pub fn deny_private(&mut self) -> &mut Self {
487 self.commit_group(Action::Deny, DestinationGroup::Private)
488 }
489
490 pub fn allow_loopback(&mut self) -> &mut Self {
499 self.commit_group(Action::Allow, DestinationGroup::Loopback)
500 }
501
502 pub fn deny_loopback(&mut self) -> &mut Self {
510 self.commit_group(Action::Deny, DestinationGroup::Loopback)
511 }
512
513 pub fn allow_link_local(&mut self) -> &mut Self {
517 self.commit_group(Action::Allow, DestinationGroup::LinkLocal)
518 }
519
520 pub fn deny_link_local(&mut self) -> &mut Self {
522 self.commit_group(Action::Deny, DestinationGroup::LinkLocal)
523 }
524
525 pub fn allow_meta(&mut self) -> &mut Self {
528 self.commit_group(Action::Allow, DestinationGroup::Metadata)
529 }
530
531 pub fn deny_meta(&mut self) -> &mut Self {
533 self.commit_group(Action::Deny, DestinationGroup::Metadata)
534 }
535
536 pub fn allow_multicast(&mut self) -> &mut Self {
538 self.commit_group(Action::Allow, DestinationGroup::Multicast)
539 }
540
541 pub fn deny_multicast(&mut self) -> &mut Self {
543 self.commit_group(Action::Deny, DestinationGroup::Multicast)
544 }
545
546 pub fn allow_host(&mut self) -> &mut Self {
551 self.commit_group(Action::Allow, DestinationGroup::Host)
552 }
553
554 pub fn deny_host(&mut self) -> &mut Self {
556 self.commit_group(Action::Deny, DestinationGroup::Host)
557 }
558
559 pub fn allow_local(&mut self) -> &mut Self {
572 self.allow_loopback();
573 self.allow_link_local();
574 self.allow_host();
575 self
576 }
577
578 pub fn deny_local(&mut self) -> &mut Self {
581 self.deny_loopback();
582 self.deny_link_local();
583 self.deny_host();
584 self
585 }
586
587 pub fn allow_domains<I, S>(&mut self, names: I) -> &mut Self
591 where
592 I: IntoIterator<Item = S>,
593 S: Into<String>,
594 {
595 for name in names {
596 self.commit_rule(Action::Allow, PendingDestination::Domain(name.into()));
597 }
598 self
599 }
600
601 pub fn deny_domains<I, S>(&mut self, names: I) -> &mut Self
603 where
604 I: IntoIterator<Item = S>,
605 S: Into<String>,
606 {
607 for name in names {
608 self.commit_rule(Action::Deny, PendingDestination::Domain(name.into()));
609 }
610 self
611 }
612
613 pub fn allow_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
615 where
616 I: IntoIterator<Item = S>,
617 S: Into<String>,
618 {
619 for suffix in suffixes {
620 self.commit_rule(
621 Action::Allow,
622 PendingDestination::DomainSuffix(suffix.into()),
623 );
624 }
625 self
626 }
627
628 pub fn deny_domain_suffixes<I, S>(&mut self, suffixes: I) -> &mut Self
630 where
631 I: IntoIterator<Item = S>,
632 S: Into<String>,
633 {
634 for suffix in suffixes {
635 self.commit_rule(
636 Action::Deny,
637 PendingDestination::DomainSuffix(suffix.into()),
638 );
639 }
640 self
641 }
642
643 pub fn allow(&mut self) -> RuleDestinationBuilder<'_> {
650 RuleDestinationBuilder {
651 rule_builder: self,
652 action: Action::Allow,
653 }
654 }
655
656 pub fn deny(&mut self) -> RuleDestinationBuilder<'_> {
658 RuleDestinationBuilder {
659 rule_builder: self,
660 action: Action::Deny,
661 }
662 }
663
664 fn commit_group(&mut self, action: Action, group: DestinationGroup) -> &mut Self {
667 self.commit_rule(
668 action,
669 PendingDestination::Resolved(Destination::Group(group)),
670 );
671 self
672 }
673
674 fn commit_rule(&mut self, action: Action, destination: PendingDestination) {
675 self.pending_rules.push(PendingRule {
676 direction: self.direction,
677 destination,
678 protocols: self.protocols.clone(),
679 ports: self.ports.clone(),
680 action,
681 });
682 }
683}
684
685#[must_use = "RuleDestinationBuilder requires a destination method (.ip, .cidr, .domain, .domain_suffix, .group, .any) to commit the rule"]
695pub struct RuleDestinationBuilder<'a> {
696 rule_builder: &'a mut RuleBuilder,
697 action: Action,
698}
699
700impl<'a> RuleDestinationBuilder<'a> {
701 pub fn ip(self, ip: impl Into<String>) -> &'a mut RuleBuilder {
705 self.rule_builder
706 .commit_rule(self.action, PendingDestination::Ip(ip.into()));
707 self.rule_builder
708 }
709
710 pub fn cidr(self, cidr: impl Into<String>) -> &'a mut RuleBuilder {
712 self.rule_builder
713 .commit_rule(self.action, PendingDestination::Cidr(cidr.into()));
714 self.rule_builder
715 }
716
717 pub fn domain(self, domain: impl Into<String>) -> &'a mut RuleBuilder {
721 self.rule_builder
722 .commit_rule(self.action, PendingDestination::Domain(domain.into()));
723 self.rule_builder
724 }
725
726 pub fn domain_suffix(self, suffix: impl Into<String>) -> &'a mut RuleBuilder {
729 self.rule_builder
730 .commit_rule(self.action, PendingDestination::DomainSuffix(suffix.into()));
731 self.rule_builder
732 }
733
734 pub fn group(self, group: DestinationGroup) -> &'a mut RuleBuilder {
736 self.rule_builder.commit_rule(
737 self.action,
738 PendingDestination::Resolved(Destination::Group(group)),
739 );
740 self.rule_builder
741 }
742
743 pub fn any(self) -> &'a mut RuleBuilder {
745 self.rule_builder
746 .commit_rule(self.action, PendingDestination::Resolved(Destination::Any));
747 self.rule_builder
748 }
749}
750
751#[derive(Debug, Clone)]
756struct PendingRule {
757 direction: Option<Direction>,
758 destination: PendingDestination,
759 protocols: Vec<Protocol>,
760 ports: Vec<PortRange>,
761 action: Action,
762}
763
764#[derive(Debug, Clone)]
765enum PendingDestination {
766 Resolved(Destination),
768 Ip(String),
769 Cidr(String),
770 Domain(String),
771 DomainSuffix(String),
772}
773
774impl PendingDestination {
775 fn parse(&self, idx: usize) -> Result<Destination, BuildError> {
776 match self {
777 PendingDestination::Resolved(d) => Ok(d.clone()),
778 PendingDestination::Ip(raw) => {
779 let ip = std::net::IpAddr::from_str(raw).map_err(|_| BuildError::InvalidIp {
780 rule_index: idx,
781 raw: raw.clone(),
782 })?;
783 let prefix = if ip.is_ipv4() { 32 } else { 128 };
786 let net = IpNetwork::new(ip, prefix).map_err(|_| BuildError::InvalidIp {
787 rule_index: idx,
788 raw: raw.clone(),
789 })?;
790 Ok(Destination::Cidr(net))
791 }
792 PendingDestination::Cidr(raw) => {
793 let net = IpNetwork::from_str(raw).map_err(|_| BuildError::InvalidCidr {
794 rule_index: idx,
795 raw: raw.clone(),
796 })?;
797 Ok(Destination::Cidr(net))
798 }
799 PendingDestination::Domain(raw) => {
800 let name =
801 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
802 rule_index: idx,
803 raw: raw.clone(),
804 source,
805 })?;
806 Ok(Destination::Domain(name))
807 }
808 PendingDestination::DomainSuffix(raw) => {
809 let name =
810 DomainName::from_str(raw).map_err(|source| BuildError::InvalidDomain {
811 rule_index: idx,
812 raw: raw.clone(),
813 source,
814 })?;
815 let name = name
816 .try_into_suffix()
817 .map_err(|source| BuildError::InvalidDomain {
818 rule_index: idx,
819 raw: raw.clone(),
820 source,
821 })?;
822 Ok(Destination::DomainSuffix(name))
823 }
824 }
825 }
826}
827
828fn warn_about_shadows(rules: &[Rule]) {
840 for (i, later) in rules.iter().enumerate() {
841 for (j, earlier) in rules.iter().take(i).enumerate() {
842 if shadows(earlier, later) {
843 tracing::warn!(
844 shadowed_index = i,
845 shadowed_by = j,
846 "rule #{i} ({:?} {:?} {:?}) is shadowed by rule #{j} ({:?} {:?} {:?}); to narrow, place the more specific rule first",
847 later.direction,
848 later.action,
849 later.destination,
850 earlier.direction,
851 earlier.action,
852 earlier.destination,
853 );
854 }
855 }
856 }
857}
858
859fn shadows(earlier: &Rule, later: &Rule) -> bool {
862 direction_covers(earlier.direction, later.direction)
863 && destination_covers(&earlier.destination, &later.destination)
864 && protocol_set_covers(&earlier.protocols, &later.protocols)
865 && port_set_covers(&earlier.ports, &later.ports)
866}
867
868fn direction_covers(earlier: Direction, later: Direction) -> bool {
869 matches!(
870 (earlier, later),
871 (Direction::Any, _)
872 | (Direction::Egress, Direction::Egress)
873 | (Direction::Ingress, Direction::Ingress)
874 )
875}
876
877fn destination_covers(earlier: &Destination, later: &Destination) -> bool {
878 match (earlier, later) {
879 (Destination::Any, _) => true,
880 (Destination::Group(eg), Destination::Group(lg)) => eg == lg,
881 (Destination::Cidr(en), Destination::Cidr(ln)) => cidr_contains(en, ln),
882 _ => false,
884 }
885}
886
887fn cidr_contains(outer: &IpNetwork, inner: &IpNetwork) -> bool {
888 match (outer, inner) {
889 (IpNetwork::V4(o), IpNetwork::V4(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
890 (IpNetwork::V6(o), IpNetwork::V6(i)) => o.prefix() <= i.prefix() && o.contains(i.network()),
891 _ => false,
892 }
893}
894
895fn protocol_set_covers(earlier: &[Protocol], later: &[Protocol]) -> bool {
896 if earlier.is_empty() {
897 return true; }
899 if later.is_empty() {
900 return false; }
902 later.iter().all(|p| earlier.contains(p))
903}
904
905fn port_set_covers(earlier: &[PortRange], later: &[PortRange]) -> bool {
906 if earlier.is_empty() {
907 return true;
908 }
909 if later.is_empty() {
910 return false;
911 }
912 later.iter().all(|lp| {
913 earlier
914 .iter()
915 .any(|ep| ep.start <= lp.start && lp.end <= ep.end)
916 })
917}
918
919impl NetworkPolicy {
924 pub fn builder() -> NetworkPolicyBuilder {
926 NetworkPolicyBuilder::new()
927 }
928}
929
930#[cfg(test)]
935mod tests {
936 use super::*;
937
938 #[test]
941 fn empty_builder_yields_asymmetric_default() {
942 let p = NetworkPolicy::builder().build().unwrap();
943 assert!(matches!(p.default_egress, Action::Deny));
944 assert!(matches!(p.default_ingress, Action::Allow));
945 assert!(p.rules.is_empty());
946 }
947
948 #[test]
951 fn defaults_set_and_override() {
952 let p = NetworkPolicy::builder()
953 .default_deny()
954 .default_ingress(Action::Allow)
955 .build()
956 .unwrap();
957 assert!(matches!(p.default_egress, Action::Deny));
958 assert!(matches!(p.default_ingress, Action::Allow));
959 }
960
961 #[test]
964 fn egress_closure_commits_one_rule_per_shortcut() {
965 let p = NetworkPolicy::builder()
966 .egress(|e| e.tcp().port(443).allow_public().allow_private())
967 .build()
968 .unwrap();
969 assert_eq!(p.rules.len(), 2);
970 assert!(matches!(p.rules[0].direction, Direction::Egress));
971 assert!(matches!(p.rules[0].action, Action::Allow));
972 assert!(matches!(
973 p.rules[0].destination,
974 Destination::Group(DestinationGroup::Public)
975 ));
976 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
977 assert_eq!(p.rules[0].ports.len(), 1);
978 assert!(matches!(
979 p.rules[1].destination,
980 Destination::Group(DestinationGroup::Private)
981 ));
982 }
983
984 #[test]
986 fn allow_local_expands_to_three_groups() {
987 let p = NetworkPolicy::builder()
988 .egress(|e| e.allow_local())
989 .build()
990 .unwrap();
991 assert_eq!(p.rules.len(), 3);
992 let groups: Vec<_> = p
993 .rules
994 .iter()
995 .map(|r| match &r.destination {
996 Destination::Group(g) => *g,
997 other => panic!("unexpected destination {other:?}"),
998 })
999 .collect();
1000 assert_eq!(
1001 groups,
1002 vec![
1003 DestinationGroup::Loopback,
1004 DestinationGroup::LinkLocal,
1005 DestinationGroup::Host,
1006 ]
1007 );
1008 }
1009
1010 #[test]
1013 fn explicit_ip_parses_at_build() {
1014 let p = NetworkPolicy::builder()
1015 .any(|a| a.deny().ip("198.51.100.5"))
1016 .build()
1017 .unwrap();
1018 assert_eq!(p.rules.len(), 1);
1019 assert!(matches!(p.rules[0].direction, Direction::Any));
1020 assert!(matches!(p.rules[0].action, Action::Deny));
1021 match &p.rules[0].destination {
1022 Destination::Cidr(net) => {
1023 assert_eq!(net.to_string(), "198.51.100.5/32");
1024 }
1025 other => panic!("expected Cidr, got {other:?}"),
1026 }
1027 }
1028
1029 #[test]
1032 fn invalid_ip_surfaces_at_build() {
1033 let result = NetworkPolicy::builder()
1034 .egress(|e| e.allow().ip("not-an-ip"))
1035 .build();
1036 match result {
1037 Err(BuildError::InvalidIp { raw, rule_index: 0 }) => {
1038 assert_eq!(raw, "not-an-ip");
1039 }
1040 other => panic!("expected InvalidIp, got {other:?}"),
1041 }
1042 }
1043
1044 #[test]
1046 fn domain_parses_to_canonical_form() {
1047 let p = NetworkPolicy::builder()
1048 .egress(|e| e.tcp().port(443).allow().domain("PyPI.Org."))
1049 .build()
1050 .unwrap();
1051 match &p.rules[0].destination {
1052 Destination::Domain(name) => assert_eq!(name.as_str(), "pypi.org"),
1053 other => panic!("expected Domain, got {other:?}"),
1054 }
1055 }
1056
1057 #[test]
1059 fn invalid_port_range_surfaces_at_build() {
1060 let result = NetworkPolicy::builder()
1061 .egress(|e| e.tcp().port_range(443, 80).allow_public())
1062 .build();
1063 match result {
1064 Err(BuildError::InvalidPortRange {
1065 lo: 443, hi: 80, ..
1066 }) => {}
1067 other => panic!("expected InvalidPortRange, got {other:?}"),
1068 }
1069 }
1070
1071 #[test]
1073 fn missing_direction_surfaces_at_build() {
1074 let result = NetworkPolicy::builder()
1075 .rule(|r| r.tcp().port(443).allow_public())
1076 .build();
1077 match result {
1078 Err(BuildError::DirectionNotSet { rule_index: 0 }) => {}
1079 other => panic!("expected DirectionNotSet, got {other:?}"),
1080 }
1081 }
1082
1083 #[test]
1085 fn icmp_in_ingress_rejected_at_build() {
1086 let result = NetworkPolicy::builder()
1087 .ingress(|i| i.icmpv4().allow_public())
1088 .build();
1089 match result {
1090 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1091 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1092 }
1093 }
1094
1095 #[test]
1097 fn icmp_in_any_direction_rejected_at_build() {
1098 let result = NetworkPolicy::builder()
1099 .any(|a| a.icmpv6().allow_public())
1100 .build();
1101 match result {
1102 Err(BuildError::IngressDoesNotSupportIcmp { rule_index: 0 }) => {}
1103 other => panic!("expected IngressDoesNotSupportIcmp, got {other:?}"),
1104 }
1105 }
1106
1107 #[test]
1109 fn duplicate_protocols_dedupe() {
1110 let p = NetworkPolicy::builder()
1111 .egress(|e| e.tcp().tcp().udp().tcp().allow_public())
1112 .build()
1113 .unwrap();
1114 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp, Protocol::Udp]);
1115 }
1116
1117 #[test]
1120 fn explicit_group_uses_typed_argument() {
1121 let p = NetworkPolicy::builder()
1122 .egress(|e| e.allow().group(DestinationGroup::Multicast))
1123 .build()
1124 .unwrap();
1125 assert!(matches!(
1126 p.rules[0].destination,
1127 Destination::Group(DestinationGroup::Multicast)
1128 ));
1129 }
1130
1131 #[test]
1135 fn chain_form_compiles_without_explicit_return() {
1136 let _ = NetworkPolicy::builder()
1137 .rule(|r| r.egress().tcp().allow_public())
1138 .build()
1139 .unwrap();
1140 }
1141
1142 #[test]
1147 fn shadowed_rule_builds_and_is_detected() {
1148 let broader = Rule {
1149 direction: Direction::Egress,
1150 destination: Destination::Cidr("10.0.0.0/8".parse().unwrap()),
1151 protocols: vec![],
1152 ports: vec![],
1153 action: Action::Allow,
1154 };
1155 let narrower = Rule {
1156 direction: Direction::Egress,
1157 destination: Destination::Cidr("10.0.0.5/32".parse().unwrap()),
1158 protocols: vec![],
1159 ports: vec![],
1160 action: Action::Allow,
1161 };
1162 assert!(
1163 shadows(&broader, &narrower),
1164 "10.0.0.0/8 should shadow 10.0.0.5/32 in same direction"
1165 );
1166 assert!(
1167 !shadows(&narrower, &broader),
1168 "10.0.0.5/32 should NOT shadow 10.0.0.0/8"
1169 );
1170
1171 let _ = NetworkPolicy::builder()
1174 .egress(|e| e.allow().cidr("10.0.0.0/8"))
1175 .egress(|e| e.allow().cidr("10.0.0.5/32"))
1176 .build()
1177 .unwrap();
1178 }
1179
1180 #[test]
1183 fn direction_cover_relations() {
1184 use Direction::*;
1185 assert!(direction_covers(Any, Egress));
1186 assert!(direction_covers(Any, Ingress));
1187 assert!(direction_covers(Any, Any));
1188 assert!(direction_covers(Egress, Egress));
1189 assert!(!direction_covers(Egress, Ingress));
1190 assert!(!direction_covers(Egress, Any)); assert!(direction_covers(Ingress, Ingress));
1192 assert!(!direction_covers(Ingress, Egress));
1193 assert!(!direction_covers(Ingress, Any));
1194 }
1195
1196 #[test]
1203 fn deny_domains_produces_one_rule_per_name() {
1204 let p = NetworkPolicy::builder()
1205 .default_allow()
1206 .egress(|e| e.deny_domains(["evil.com", "tracker.example"]))
1207 .build()
1208 .unwrap();
1209 assert_eq!(p.rules.len(), 2);
1210 for rule in &p.rules {
1211 assert_eq!(rule.action, Action::Deny);
1212 assert_eq!(rule.direction, Direction::Egress);
1213 assert!(rule.protocols.is_empty(), "no protocol filter");
1214 assert!(rule.ports.is_empty(), "no port filter");
1215 }
1216 assert!(matches!(
1217 &p.rules[0].destination,
1218 Destination::Domain(d) if d.as_str() == "evil.com",
1219 ));
1220 assert!(matches!(
1221 &p.rules[1].destination,
1222 Destination::Domain(d) if d.as_str() == "tracker.example",
1223 ));
1224 }
1225
1226 #[test]
1229 fn deny_domain_suffixes_produces_one_rule_per_suffix() {
1230 let p = NetworkPolicy::builder()
1231 .default_allow()
1232 .egress(|e| e.deny_domain_suffixes([".ads.example", ".doubleclick.net"]))
1233 .build()
1234 .unwrap();
1235 assert_eq!(p.rules.len(), 2);
1236 assert!(matches!(
1237 &p.rules[0].destination,
1238 Destination::DomainSuffix(d) if d.as_str() == "ads.example",
1239 ));
1240 assert!(matches!(
1241 &p.rules[1].destination,
1242 Destination::DomainSuffix(d) if d.as_str() == "doubleclick.net",
1243 ));
1244 }
1245
1246 #[test]
1249 fn deny_domains_inherits_protocol_and_port_filter() {
1250 let p = NetworkPolicy::builder()
1251 .default_allow()
1252 .egress(|e| e.tcp().port(443).deny_domains(["evil.com"]))
1253 .build()
1254 .unwrap();
1255 assert_eq!(p.rules[0].protocols, vec![Protocol::Tcp]);
1256 assert_eq!(p.rules[0].ports, vec![PortRange::single(443)]);
1257 }
1258
1259 #[test]
1262 fn allow_domains_produces_allow_rules() {
1263 let p = NetworkPolicy::builder()
1264 .default_deny()
1265 .egress(|e| e.allow_domains(["pypi.org", "files.pythonhosted.org"]))
1266 .build()
1267 .unwrap();
1268 assert_eq!(p.rules.len(), 2);
1269 for rule in &p.rules {
1270 assert_eq!(rule.action, Action::Allow);
1271 }
1272 }
1273
1274 #[test]
1276 fn deny_domains_empty_input_is_noop() {
1277 let p = NetworkPolicy::builder()
1278 .default_allow()
1279 .egress(|e| e.deny_domains(Vec::<&str>::new()))
1280 .build()
1281 .unwrap();
1282 assert!(p.rules.is_empty());
1283 }
1284
1285 #[test]
1289 fn deny_domains_invalid_input_surfaces_at_build() {
1290 let result = NetworkPolicy::builder()
1291 .default_allow()
1292 .egress(|e| e.deny_domains(["evil.com", "not a domain!"]))
1293 .build();
1294 match result {
1295 Err(BuildError::InvalidDomain {
1296 raw, rule_index, ..
1297 }) => {
1298 assert_eq!(raw, "not a domain!");
1299 assert_eq!(rule_index, 1);
1302 }
1303 other => panic!("expected InvalidDomain, got {other:?}"),
1304 }
1305 }
1306}