1use std::collections::{HashMap, HashSet};
7use std::io;
8use std::net::IpAddr;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::SandboxError;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17pub struct IpCidr {
18 pub addr: IpAddr,
19 pub prefix_len: u8,
20}
21
22impl IpCidr {
23 pub fn parse(s: &str) -> Result<Self, SandboxError> {
27 let (addr_str, prefix) = match s.split_once('/') {
28 Some((a, p)) => {
29 let len: u8 = p.parse().map_err(|_| {
30 SandboxError::Invalid(format!("invalid prefix length in `{}`", s))
31 })?;
32 (a, Some(len))
33 }
34 None => (s, None),
35 };
36 let addr: IpAddr = addr_str.parse().map_err(|_| {
37 SandboxError::Invalid(format!("`{}` is not a valid IP address", s))
38 })?;
39 let max = match addr {
40 IpAddr::V4(_) => 32u8,
41 IpAddr::V6(_) => 128u8,
42 };
43 let prefix_len = prefix.unwrap_or(max);
44 if prefix_len > max {
45 return Err(SandboxError::Invalid(format!(
46 "prefix /{} too large for {} in `{}`",
47 prefix_len,
48 if max == 32 { "IPv4" } else { "IPv6" },
49 s
50 )));
51 }
52 Ok(IpCidr { addr, prefix_len })
53 }
54
55 pub fn is_single_host(&self) -> bool {
58 match self.addr {
59 IpAddr::V4(_) => self.prefix_len == 32,
60 IpAddr::V6(_) => self.prefix_len == 128,
61 }
62 }
63
64 pub fn contains(&self, ip: IpAddr) -> bool {
67 match (self.addr, ip) {
68 (IpAddr::V4(net), IpAddr::V4(ip)) => {
69 if self.prefix_len == 0 {
70 return true;
71 }
72 let mask = u32::MAX << (32 - self.prefix_len);
73 (u32::from(net) & mask) == (u32::from(ip) & mask)
74 }
75 (IpAddr::V6(net), IpAddr::V6(ip)) => {
76 if self.prefix_len == 0 {
77 return true;
78 }
79 let mask = u128::MAX << (128 - self.prefix_len);
80 (u128::from(net) & mask) == (u128::from(ip) & mask)
81 }
82 _ => false,
83 }
84 }
85}
86
87impl std::fmt::Display for IpCidr {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 if self.is_single_host() {
92 write!(f, "{}", self.addr)
93 } else {
94 write!(f, "{}/{}", self.addr, self.prefix_len)
95 }
96 }
97}
98
99#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub enum NetTarget {
106 AnyIp,
108 Cidr(IpCidr),
110 Host(String),
112}
113
114#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
122pub struct NetRule {
123 pub protocol: Protocol,
125 pub target: NetTarget,
127 pub ports: Vec<u16>,
130 #[serde(default)]
132 pub all_ports: bool,
133}
134
135pub type NetAllow = NetRule;
138pub type NetDeny = NetRule;
139
140impl NetRule {
141 pub fn parse_allow(spec: &str) -> Result<Vec<NetRule>, SandboxError> {
156 Self::parse_spec(spec, "--net-allow", true)
157 }
158
159 pub fn parse_deny(spec: &str) -> Result<Vec<NetDeny>, SandboxError> {
164 Self::parse_spec(spec, "--net-deny", false)
165 }
166
167 fn parse_spec(spec: &str, label: &str, allow_hosts: bool) -> Result<Vec<NetRule>, SandboxError> {
171 let (scheme, rest) = match spec.split_once("://") {
172 Some((scheme, body)) => {
173 let proto = Protocol::parse(scheme).ok_or_else(|| {
174 SandboxError::Invalid(format!(
175 "{}: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)",
176 label, scheme, spec
177 ))
178 })?;
179 (Some(proto), body)
180 }
181 None => (None, spec),
182 };
183
184 if scheme == Some(Protocol::Icmp) {
186 if rest.is_empty() {
187 return Err(SandboxError::Invalid(format!(
188 "{}: icmp rule needs a host/IP or `*`, got `{}`",
189 label, spec
190 )));
191 }
192 if rest != "*" && IpCidr::parse(rest).is_err() && rest.contains(':') {
195 return Err(SandboxError::Invalid(format!(
196 "{}: icmp rule takes no port, got `{}`",
197 label, spec
198 )));
199 }
200 return Ok(vec![NetRule {
201 protocol: Protocol::Icmp,
202 target: parse_target(rest, label, allow_hosts)?,
203 ports: Vec::new(),
204 all_ports: true,
205 }]);
206 }
207
208 let (target, ports, all_ports) = if let Some(stripped) = rest.strip_prefix('[') {
210 let (inside, port_part) = stripped.rsplit_once("]:").ok_or_else(|| {
211 SandboxError::Invalid(format!("{}: malformed bracketed address in `{}`", label, spec))
212 })?;
213 let (ports, all_ports) = parse_ports(port_part, label, spec)?;
214 (NetTarget::Cidr(IpCidr::parse(inside)?), ports, all_ports)
215 } else if rest.is_empty() {
216 return Err(SandboxError::Invalid(format!(
219 "{}: empty rule in `{}` (use `*` for any host)",
220 label, spec
221 )));
222 } else if let Ok(cidr) = IpCidr::parse(rest) {
223 (NetTarget::Cidr(cidr), Vec::new(), true)
227 } else {
228 let (host_part, port_part) = match rest.rsplit_once(':') {
232 Some((h, p)) => (h, Some(p)),
233 None => (rest, None),
234 };
235 let target = parse_target(host_part, label, allow_hosts)?;
236 let (ports, all_ports) = match port_part {
237 Some(p) => parse_ports(p, label, spec)?,
238 None => (Vec::new(), true),
239 };
240 (target, ports, all_ports)
241 };
242
243 let protocols = match scheme {
246 Some(p) => vec![p],
247 None => vec![Protocol::Tcp, Protocol::Udp],
248 };
249 Ok(protocols
250 .into_iter()
251 .map(|protocol| NetRule {
252 protocol,
253 target: target.clone(),
254 ports: ports.clone(),
255 all_ports,
256 })
257 .collect())
258 }
259}
260
261fn parse_target(s: &str, label: &str, allow_hosts: bool) -> Result<NetTarget, SandboxError> {
264 match s {
265 "" | "*" => Ok(NetTarget::AnyIp),
266 _ if s.contains('/') => Ok(NetTarget::Cidr(
269 IpCidr::parse(s).map_err(|e| SandboxError::Invalid(format!("{}: {}", label, e)))?,
270 )),
271 _ => {
272 if let Ok(cidr) = IpCidr::parse(s) {
273 Ok(NetTarget::Cidr(cidr))
274 } else if allow_hosts {
275 Ok(NetTarget::Host(s.to_string()))
276 } else {
277 Err(SandboxError::Invalid(format!(
278 "{}: `{}` is not an IP or CIDR (hostnames are not allowed; \
279 use --http-deny for domains)",
280 label, s
281 )))
282 }
283 }
284 }
285}
286
287fn parse_ports(s: &str, label: &str, full: &str) -> Result<(Vec<u16>, bool), SandboxError> {
290 let mut ports = Vec::new();
291 let mut saw_wildcard = false;
292 for p in s.split(',') {
293 let p = p.trim();
294 if p == "*" {
295 saw_wildcard = true;
296 continue;
297 }
298 let n: u16 = p.parse().map_err(|_| {
299 SandboxError::Invalid(format!("{}: invalid port `{}` in `{}`", label, p, full))
300 })?;
301 if n == 0 {
302 return Err(SandboxError::Invalid(format!(
303 "{}: port 0 is not valid in `{}`",
304 label, full
305 )));
306 }
307 ports.push(n);
308 }
309 if saw_wildcard && !ports.is_empty() {
310 return Err(SandboxError::Invalid(format!(
311 "{}: cannot mix `*` with concrete ports in `{}`",
312 label, full
313 )));
314 }
315 if !saw_wildcard && ports.is_empty() {
316 return Err(SandboxError::Invalid(format!(
317 "{}: at least one port required in `{}`",
318 label, full
319 )));
320 }
321 Ok((ports, saw_wildcard))
322}
323
324#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
340#[serde(rename_all = "lowercase")]
341pub enum Protocol {
342 Tcp,
343 Udp,
344 Icmp,
345}
346
347impl Protocol {
348 fn parse(s: &str) -> Option<Self> {
349 match s {
350 "tcp" => Some(Protocol::Tcp),
351 "udp" => Some(Protocol::Udp),
352 "icmp" => Some(Protocol::Icmp),
353 _ => None,
354 }
355 }
356}
357
358pub struct ResolvedNetAllow {
364 pub per_ip: HashMap<IpAddr, HashSet<u16>>,
368 pub per_ip_all_ports: HashSet<IpAddr>,
373 pub cidrs: Vec<(IpCidr, crate::seccomp::notif::PortAllow)>,
377 pub any_ip_ports: HashSet<u16>,
379 pub any_ip_all_ports: bool,
383}
384
385pub struct ResolvedNetAllowSet {
391 pub tcp: ResolvedNetAllow,
392 pub udp: ResolvedNetAllow,
393 pub icmp: ResolvedNetAllow,
394 pub concrete_host_entries: String,
401}
402
403pub async fn resolve_net_allow(
412 rules: &[NetAllow],
413) -> io::Result<ResolvedNetAllowSet> {
414 use crate::seccomp::notif::PortAllow;
415
416 let mut host_ips: HashMap<&str, Vec<IpAddr>> = HashMap::new();
422 let mut concrete_host_entries = String::new();
423 for rule in rules {
424 if let NetTarget::Host(host) = &rule.target {
425 if host_ips.contains_key(host.as_str()) {
426 continue;
427 }
428 let addr = format!("{}:0", host);
429 let resolved = tokio::net::lookup_host(addr.as_str()).await.map_err(|e| {
430 io::Error::new(
431 e.kind(),
432 format!("failed to resolve host '{}': {}", host, e),
433 )
434 })?;
435 let ips: Vec<IpAddr> = resolved.map(|sa| sa.ip()).collect();
436 for ip in &ips {
437 concrete_host_entries.push_str(&format!("{} {}\n", ip, host));
438 }
439 host_ips.insert(host.as_str(), ips);
440 }
441 }
442
443 let per_proto = |target: Protocol| {
444 let mut per_ip: HashMap<IpAddr, HashSet<u16>> = HashMap::new();
445 let mut per_ip_all_ports: HashSet<IpAddr> = HashSet::new();
446 let mut cidrs: Vec<(IpCidr, PortAllow)> = Vec::new();
447 let mut any_ip_ports: HashSet<u16> = HashSet::new();
448 let mut any_ip_all_ports = false;
449
450 for rule in rules.iter().filter(|r| r.protocol == target) {
451 match &rule.target {
452 NetTarget::AnyIp => {
453 if rule.all_ports || target == Protocol::Icmp {
454 any_ip_all_ports = true;
457 } else {
458 for &p in &rule.ports {
459 any_ip_ports.insert(p);
460 }
461 }
462 }
463 NetTarget::Cidr(c) => {
464 let pa = if rule.all_ports || target == Protocol::Icmp {
467 PortAllow::Any
468 } else {
469 PortAllow::Specific(rule.ports.iter().copied().collect())
470 };
471 cidrs.push((*c, pa));
472 }
473 NetTarget::Host(host) => {
474 for &ip in &host_ips[host.as_str()] {
475 if rule.all_ports || target == Protocol::Icmp {
476 per_ip_all_ports.insert(ip);
477 per_ip.entry(ip).or_default();
478 } else {
479 let entry = per_ip.entry(ip).or_default();
480 for &p in &rule.ports {
481 entry.insert(p);
482 }
483 }
484 }
485 }
486 }
487 }
488
489 ResolvedNetAllow {
490 per_ip,
491 per_ip_all_ports,
492 cidrs,
493 any_ip_ports,
494 any_ip_all_ports,
495 }
496 };
497
498 Ok(ResolvedNetAllowSet {
499 tcp: per_proto(Protocol::Tcp),
500 udp: per_proto(Protocol::Udp),
501 icmp: per_proto(Protocol::Icmp),
502 concrete_host_entries,
503 })
504}
505
506pub struct ResolvedNetDenySet {
508 pub tcp: crate::seccomp::notif::NetworkPolicy,
509 pub udp: crate::seccomp::notif::NetworkPolicy,
510 pub icmp: crate::seccomp::notif::NetworkPolicy,
511}
512
513pub fn resolve_net_deny(rules: &[NetDeny]) -> ResolvedNetDenySet {
516 use crate::seccomp::notif::{NetworkPolicy, PortAllow};
517
518 let per_proto = |target: Protocol| -> NetworkPolicy {
519 let mut cidrs: Vec<(IpCidr, PortAllow)> = Vec::new();
520 let mut any_ip_ports: HashSet<u16> = HashSet::new();
521 let mut deny_all = false;
522 let mut saw_rule = false;
523
524 for rule in rules.iter().filter(|r| r.protocol == target) {
525 saw_rule = true;
526 match &rule.target {
527 NetTarget::AnyIp => {
528 if rule.all_ports || target == Protocol::Icmp {
529 deny_all = true;
530 } else {
531 for &p in &rule.ports {
532 any_ip_ports.insert(p);
533 }
534 }
535 }
536 NetTarget::Cidr(c) => {
537 let pa = if rule.all_ports || target == Protocol::Icmp {
538 PortAllow::Any
539 } else {
540 PortAllow::Specific(rule.ports.iter().copied().collect())
541 };
542 cidrs.push((*c, pa));
543 }
544 NetTarget::Host(_) => unreachable!("net-deny rejects hostnames"),
547 }
548 }
549
550 if !saw_rule {
551 NetworkPolicy::Unrestricted
552 } else {
553 NetworkPolicy::DenyList {
554 cidrs,
555 any_ip_ports,
556 deny_all,
557 }
558 }
559 };
560
561 ResolvedNetDenySet {
562 tcp: per_proto(Protocol::Tcp),
563 udp: per_proto(Protocol::Udp),
564 icmp: per_proto(Protocol::Icmp),
565 }
566}
567
568pub fn compose_virtual_etc_hosts(
585 chroot_root: Option<&std::path::Path>,
586 concrete_host_entries: &str,
587) -> String {
588 let mut out = String::new();
589 let mut has_v4_localhost = false;
590 let mut has_v6_localhost = false;
591
592 if let Some(root) = chroot_root {
593 if let Ok(image) = std::fs::read_to_string(root.join("etc").join("hosts")) {
594 for line in image.lines() {
595 let stripped = line.split('#').next().unwrap_or("");
598 let mut parts = stripped.split_whitespace();
599 let Some(ip) = parts.next() else { continue };
600 for name in parts {
601 if name == "localhost" {
602 if ip == "127.0.0.1" {
603 has_v4_localhost = true;
604 } else if ip == "::1" {
605 has_v6_localhost = true;
606 }
607 }
608 }
609 }
610 out.push_str(&image);
611 if !out.is_empty() && !out.ends_with('\n') {
612 out.push('\n');
613 }
614 }
615 }
616
617 if !has_v4_localhost {
618 out.push_str("127.0.0.1 localhost\n");
619 }
620 if !has_v6_localhost {
621 out.push_str("::1 localhost\n");
622 }
623 out.push_str(concrete_host_entries);
624 out
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 fn allow_one(spec: &str) -> NetRule {
634 let mut v = NetRule::parse_allow(spec).unwrap();
635 assert_eq!(v.len(), 1, "expected a single rule for `{spec}`");
636 v.remove(0)
637 }
638
639 fn allow_pair(spec: &str) -> NetRule {
643 pair(NetRule::parse_allow(spec).unwrap(), spec)
644 }
645
646 fn deny_one(spec: &str) -> NetRule {
647 let mut v = NetRule::parse_deny(spec).unwrap();
648 assert_eq!(v.len(), 1, "expected a single rule for `{spec}`");
649 v.remove(0)
650 }
651
652 fn deny_pair(spec: &str) -> NetRule {
653 pair(NetRule::parse_deny(spec).unwrap(), spec)
654 }
655
656 fn pair(mut v: Vec<NetRule>, spec: &str) -> NetRule {
657 assert_eq!(v.len(), 2, "expected a TCP+UDP pair for `{spec}`");
658 let udp = v.pop().unwrap();
659 let tcp = v.pop().unwrap();
660 assert_eq!(tcp.protocol, Protocol::Tcp, "spec `{spec}`");
661 assert_eq!(udp.protocol, Protocol::Udp, "spec `{spec}`");
662 assert_eq!(tcp.target, udp.target, "spec `{spec}`");
663 assert_eq!(tcp.ports, udp.ports, "spec `{spec}`");
664 assert_eq!(tcp.all_ports, udp.all_ports, "spec `{spec}`");
665 tcp
666 }
667
668 #[test]
671 fn netallow_parse_concrete_host_port() {
672 let r = allow_pair("example.com:443");
673 assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com"));
674 assert_eq!(r.ports, vec![443]);
675 assert!(!r.all_ports);
676 }
677
678 #[test]
679 fn netallow_parse_any_host_port() {
680 let r = allow_pair(":8080");
681 assert_eq!(r.target, NetTarget::AnyIp);
682 assert_eq!(r.ports, vec![8080]);
683 assert!(!r.all_ports);
684
685 let r = allow_pair("*:8080");
686 assert_eq!(r.target, NetTarget::AnyIp);
687 assert_eq!(r.ports, vec![8080]);
688 assert!(!r.all_ports);
689 }
690
691 #[test]
692 fn netallow_parse_multiple_ports() {
693 let r = allow_pair("github.com:22,80,443");
694 assert!(matches!(&r.target, NetTarget::Host(h) if h == "github.com"));
695 assert_eq!(r.ports, vec![22, 80, 443]);
696 assert!(!r.all_ports);
697 }
698
699 #[test]
700 fn netallow_parse_wildcard_any_host_any_port_colon() {
701 let r = allow_pair(":*");
702 assert_eq!(r.target, NetTarget::AnyIp);
703 assert!(r.ports.is_empty());
704 assert!(r.all_ports);
705 }
706
707 #[test]
708 fn netallow_parse_wildcard_any_host_any_port_star() {
709 let r = allow_pair("*:*");
710 assert_eq!(r.target, NetTarget::AnyIp);
711 assert!(r.ports.is_empty());
712 assert!(r.all_ports);
713 }
714
715 #[test]
716 fn netallow_parse_wildcard_concrete_host_any_port() {
717 let r = allow_pair("example.com:*");
718 assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com"));
719 assert!(r.ports.is_empty());
720 assert!(r.all_ports);
721 }
722
723 #[test]
724 fn netallow_parse_rejects_mixed_wildcard_and_concrete() {
725 let err = NetRule::parse_allow("example.com:80,*").unwrap_err();
729 assert!(format!("{}", err).contains("cannot mix"));
730 let err = NetRule::parse_allow("example.com:*,80").unwrap_err();
731 assert!(format!("{}", err).contains("cannot mix"));
732 }
733
734 #[test]
735 fn netallow_parse_rejects_port_zero() {
736 let err = NetRule::parse_allow("example.com:0").unwrap_err();
737 assert!(format!("{}", err).contains("port 0"));
738 }
739
740 #[test]
741 fn netallow_parse_rejects_empty_port() {
742 let err = NetRule::parse_allow("example.com:").unwrap_err();
743 assert!(format!("{}", err).contains("invalid port"));
744 }
745
746 #[test]
747 fn netallow_bare_host_is_all_ports() {
748 let r = allow_pair("example.com");
751 assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com"));
752 assert!(r.all_ports);
753 assert!(r.ports.is_empty());
754 }
755
756 #[test]
757 fn netallow_bare_star_is_any_host_all_ports() {
758 let r = allow_pair("*");
759 assert_eq!(r.target, NetTarget::AnyIp);
760 assert!(r.all_ports);
761 assert!(r.ports.is_empty());
762 }
763
764 #[test]
765 fn netallow_empty_spec_rejected() {
766 assert!(NetRule::parse_allow("").is_err());
767 assert!(NetRule::parse_allow("tcp://").is_err());
768 }
769
770 #[test]
771 fn netallow_cidr_target_with_port() {
772 let r = allow_pair("10.0.0.0/8:80");
775 assert!(matches!(&r.target, NetTarget::Cidr(c) if !c.is_single_host()));
776 assert_eq!(r.ports, vec![80]);
777 assert!(!r.all_ports);
778 }
779
780 #[test]
781 fn netallow_ipv6_literal_and_bracket() {
782 let lo: std::net::IpAddr = "::1".parse().unwrap();
783 let r = allow_pair("::1");
785 assert!(matches!(&r.target, NetTarget::Cidr(c) if c.addr == lo && c.is_single_host()));
786 assert!(r.all_ports);
787 let r = allow_pair("[::1]:443");
789 assert!(matches!(&r.target, NetTarget::Cidr(c) if c.addr == lo && c.is_single_host()));
790 assert_eq!(r.ports, vec![443]);
791 let r = allow_pair("fc00::/7");
793 assert!(matches!(&r.target, NetTarget::Cidr(c) if !c.is_single_host()));
794 assert!(r.all_ports);
795 }
796
797 #[tokio::test]
798 async fn test_resolve_net_allow_cidr_no_dns() {
799 let rules = vec![
802 NetAllow { protocol: Protocol::Tcp, target: NetTarget::Cidr(IpCidr::parse("10.0.0.0/8").unwrap()), ports: vec![80], all_ports: false },
803 NetAllow { protocol: Protocol::Tcp, target: NetTarget::Cidr(IpCidr::parse("1.2.3.4").unwrap()), ports: vec![], all_ports: true },
804 ];
805 let resolved = resolve_net_allow(&rules).await.unwrap();
806 assert_eq!(resolved.tcp.cidrs.len(), 2);
807 assert!(resolved.tcp.per_ip.is_empty());
808 assert!(resolved.concrete_host_entries.is_empty());
809 }
810
811 #[test]
812 fn netallow_parse_repeated_wildcard_is_idempotent() {
813 let r = allow_pair(":*,*");
816 assert!(r.all_ports);
817 assert!(r.ports.is_empty());
818 }
819
820 #[test]
823 fn netallow_schemeless_expands_to_tcp_and_udp() {
824 let r = allow_pair("example.com:443");
827 assert_eq!(r.protocol, Protocol::Tcp);
828 }
829
830 #[test]
831 fn netallow_explicit_tcp_scheme_is_tcp_only() {
832 let r = allow_one("tcp://example.com:443");
833 assert_eq!(r.protocol, Protocol::Tcp);
834 assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com"));
835 assert_eq!(r.ports, vec![443]);
836 }
837
838 #[test]
839 fn netallow_udp_scheme_with_host_port() {
840 let r = allow_one("udp://1.1.1.1:53");
841 assert_eq!(r.protocol, Protocol::Udp);
842 let one: std::net::IpAddr = "1.1.1.1".parse().unwrap();
844 assert!(matches!(&r.target, NetTarget::Cidr(c) if c.addr == one && c.is_single_host()));
845 assert_eq!(r.ports, vec![53]);
846 }
847
848 #[test]
849 fn netallow_udp_wildcard_any_anywhere() {
850 let r = allow_one("udp://*:*");
852 assert_eq!(r.protocol, Protocol::Udp);
853 assert_eq!(r.target, NetTarget::AnyIp);
854 assert!(r.all_ports);
855 }
856
857 #[test]
858 fn netallow_icmp_scheme_with_host() {
859 let r = allow_one("icmp://github.com");
860 assert_eq!(r.protocol, Protocol::Icmp);
861 assert!(matches!(&r.target, NetTarget::Host(h) if h == "github.com"));
862 assert!(r.ports.is_empty());
863 assert!(r.all_ports);
865 }
866
867 #[test]
868 fn netallow_icmp_wildcard() {
869 let r = allow_one("icmp://*");
872 assert_eq!(r.protocol, Protocol::Icmp);
873 assert_eq!(r.target, NetTarget::AnyIp);
874 }
875
876 #[test]
877 fn netallow_icmp_rejects_port() {
878 let err = NetRule::parse_allow("icmp://github.com:80").unwrap_err();
882 assert!(format!("{}", err).contains("icmp rule takes no port"));
883 }
884
885 #[test]
886 fn netallow_icmp_rejects_empty_body() {
887 let err = NetRule::parse_allow("icmp://").unwrap_err();
888 assert!(format!("{}", err).contains("needs a host/IP or `*`"));
889 }
890
891 #[test]
892 fn netallow_unknown_scheme_rejected() {
893 for spec in ["sctp://host:1234", "icmp-raw://*"] {
896 let err = NetRule::parse_allow(spec).unwrap_err();
897 assert!(format!("{}", err).contains("unknown scheme"), "spec: {}", spec);
898 }
899 }
900
901 #[tokio::test]
902 async fn test_resolve_net_allow_empty() {
903 let resolved = resolve_net_allow(&[]).await.unwrap();
904 assert!(resolved.tcp.per_ip.is_empty());
905 assert!(resolved.tcp.any_ip_ports.is_empty());
906 assert!(resolved.udp.per_ip.is_empty());
907 assert!(resolved.icmp.per_ip.is_empty());
908 assert!(resolved.concrete_host_entries.is_empty());
910 }
911
912 #[tokio::test]
913 async fn test_resolve_net_allow_concrete_host() {
914 let rules = vec![NetAllow {
915 protocol: Protocol::Tcp,
916 target: NetTarget::Host("localhost".to_string()),
917 ports: vec![80, 443],
918 all_ports: false,
919 }];
920 let resolved = resolve_net_allow(&rules).await.unwrap();
921 assert!(!resolved.tcp.per_ip.is_empty());
924 for ports in resolved.tcp.per_ip.values() {
925 assert!(ports.contains(&80));
926 assert!(ports.contains(&443));
927 }
928 assert!(resolved.udp.per_ip.is_empty());
929 assert!(resolved.icmp.per_ip.is_empty());
930 assert!(resolved.concrete_host_entries.contains("127.0.0.1 localhost"));
932 }
933
934 #[tokio::test]
935 async fn test_resolve_net_allow_any_ip() {
936 let rules = vec![NetAllow {
937 protocol: Protocol::Tcp,
938 target: NetTarget::AnyIp,
939 ports: vec![8080],
940 all_ports: false,
941 }];
942 let resolved = resolve_net_allow(&rules).await.unwrap();
943 assert!(resolved.tcp.per_ip.is_empty());
944 assert!(resolved.tcp.any_ip_ports.contains(&8080));
945 assert!(!resolved.tcp.any_ip_all_ports);
946 assert!(resolved.concrete_host_entries.is_empty());
948 }
949
950 #[tokio::test]
951 async fn test_resolve_net_allow_any_ip_all_ports() {
952 let rules = vec![NetAllow {
954 protocol: Protocol::Tcp,
955 target: NetTarget::AnyIp,
956 ports: vec![],
957 all_ports: true,
958 }];
959 let resolved = resolve_net_allow(&rules).await.unwrap();
960 assert!(resolved.tcp.any_ip_all_ports);
961 assert!(resolved.tcp.per_ip.is_empty());
962 assert!(resolved.tcp.per_ip_all_ports.is_empty());
963 assert!(resolved.tcp.any_ip_ports.is_empty());
964 assert!(!resolved.udp.any_ip_all_ports);
966 assert!(!resolved.icmp.any_ip_all_ports);
967 }
968
969 #[tokio::test]
970 async fn test_resolve_net_allow_concrete_host_all_ports() {
971 let rules = vec![NetAllow {
973 protocol: Protocol::Tcp,
974 target: NetTarget::Host("localhost".to_string()),
975 ports: vec![],
976 all_ports: true,
977 }];
978 let resolved = resolve_net_allow(&rules).await.unwrap();
979 assert!(!resolved.tcp.any_ip_all_ports);
980 assert!(
981 !resolved.tcp.per_ip_all_ports.is_empty(),
982 "localhost should resolve to at least one IP marked as any-port"
983 );
984 for ip in resolved.tcp.per_ip_all_ports.iter() {
985 assert!(resolved.tcp.per_ip.contains_key(ip));
986 }
987 assert!(resolved.concrete_host_entries.contains("localhost"));
988 }
989
990 #[tokio::test]
991 async fn test_resolve_net_allow_mixed_wildcard_and_concrete() {
992 let rules = vec![
997 NetAllow {
998 protocol: Protocol::Tcp,
999 target: NetTarget::AnyIp,
1000 ports: vec![],
1001 all_ports: true,
1002 },
1003 NetAllow {
1004 protocol: Protocol::Tcp,
1005 target: NetTarget::Host("localhost".to_string()),
1006 ports: vec![22],
1007 all_ports: false,
1008 },
1009 ];
1010 let resolved = resolve_net_allow(&rules).await.unwrap();
1011 assert!(resolved.tcp.any_ip_all_ports);
1012 assert!(!resolved.tcp.per_ip.is_empty());
1013 }
1014
1015 #[tokio::test]
1020 async fn test_resolve_per_protocol_isolation() {
1021 let rules = vec![
1024 NetAllow {
1025 protocol: Protocol::Tcp,
1026 target: NetTarget::Host("localhost".to_string()),
1027 ports: vec![443],
1028 all_ports: false,
1029 },
1030 NetAllow {
1031 protocol: Protocol::Udp,
1032 target: NetTarget::AnyIp,
1033 ports: vec![53],
1034 all_ports: false,
1035 },
1036 ];
1037 let resolved = resolve_net_allow(&rules).await.unwrap();
1038 assert!(
1039 !resolved.tcp.per_ip.is_empty(),
1040 "TCP rule should populate tcp set"
1041 );
1042 assert!(
1043 resolved.udp.any_ip_ports.contains(&53),
1044 "UDP rule should populate udp set"
1045 );
1046 for ports in resolved.tcp.per_ip.values() {
1049 assert!(!ports.contains(&53), "UDP port leaked into TCP set");
1050 }
1051 assert!(!resolved.udp.any_ip_ports.contains(&443), "TCP port leaked into UDP set");
1052 }
1053
1054 #[tokio::test]
1055 async fn test_resolve_icmp_no_ports() {
1056 let rules = vec![NetAllow {
1059 protocol: Protocol::Icmp,
1060 target: NetTarget::Host("localhost".to_string()),
1061 ports: vec![],
1062 all_ports: false,
1063 }];
1064 let resolved = resolve_net_allow(&rules).await.unwrap();
1065 assert!(
1066 !resolved.icmp.per_ip.is_empty(),
1067 "icmp host should populate per_ip"
1068 );
1069 assert!(
1070 !resolved.icmp.per_ip_all_ports.is_empty(),
1071 "icmp host should mark per_ip_all_ports (no port check)"
1072 );
1073 assert!(resolved.icmp.any_ip_ports.is_empty());
1074 assert!(resolved.tcp.per_ip.is_empty());
1076 assert!(resolved.udp.per_ip.is_empty());
1077 }
1078
1079 #[tokio::test]
1080 async fn test_resolve_icmp_wildcard() {
1081 let rules = vec![NetAllow {
1083 protocol: Protocol::Icmp,
1084 target: NetTarget::AnyIp,
1085 ports: vec![],
1086 all_ports: false,
1087 }];
1088 let resolved = resolve_net_allow(&rules).await.unwrap();
1089 assert!(resolved.icmp.any_ip_all_ports);
1090 assert!(!resolved.tcp.any_ip_all_ports);
1091 }
1092
1093 use std::io::Write;
1098
1099 fn temp_rootfs_with_hosts(name: &str, hosts_content: Option<&str>) -> std::path::PathBuf {
1100 let dir = std::env::temp_dir().join(format!(
1101 "sandlock-test-compose-hosts-{}-{}",
1102 name, std::process::id()
1103 ));
1104 let _ = std::fs::create_dir_all(dir.join("etc"));
1105 if let Some(content) = hosts_content {
1106 let mut f = std::fs::File::create(dir.join("etc").join("hosts")).unwrap();
1107 f.write_all(content.as_bytes()).unwrap();
1108 }
1109 dir
1110 }
1111
1112 #[test]
1113 fn compose_no_chroot_emits_loopback_base() {
1114 let out = compose_virtual_etc_hosts(None, "");
1117 assert_eq!(out, "127.0.0.1 localhost\n::1 localhost\n");
1118 }
1119
1120 #[test]
1121 fn compose_no_chroot_appends_concrete_entries() {
1122 let out = compose_virtual_etc_hosts(None, "10.0.0.1 api\n");
1123 assert_eq!(out, "127.0.0.1 localhost\n::1 localhost\n10.0.0.1 api\n");
1124 }
1125
1126 #[test]
1127 fn compose_chroot_seeds_from_image_and_injects_missing_loopback() {
1128 let rootfs = temp_rootfs_with_hosts(
1132 "no-localhost",
1133 Some("10.0.0.5 myimage.local\n"),
1134 );
1135 let out = compose_virtual_etc_hosts(Some(&rootfs), "");
1136 assert!(out.contains("10.0.0.5 myimage.local"), "image entry missing: {out}");
1137 assert!(out.contains("127.0.0.1 localhost"), "v4 loopback missing: {out}");
1138 assert!(out.contains("::1 localhost"), "v6 loopback missing: {out}");
1139 let _ = std::fs::remove_dir_all(&rootfs);
1140 }
1141
1142 #[test]
1143 fn compose_chroot_does_not_duplicate_existing_loopback() {
1144 let rootfs = temp_rootfs_with_hosts(
1146 "both-localhost",
1147 Some("127.0.0.1 localhost\n::1 localhost\n10.0.0.5 myimage.local\n"),
1148 );
1149 let out = compose_virtual_etc_hosts(Some(&rootfs), "");
1150 assert_eq!(out.matches("127.0.0.1 localhost").count(), 1, "v4 dup'd: {out}");
1151 assert_eq!(out.matches("::1 localhost").count(), 1, "v6 dup'd: {out}");
1152 assert!(out.contains("10.0.0.5 myimage.local"));
1153 let _ = std::fs::remove_dir_all(&rootfs);
1154 }
1155
1156 #[test]
1157 fn compose_chroot_injects_only_missing_family() {
1158 let rootfs = temp_rootfs_with_hosts(
1160 "only-v4-localhost",
1161 Some("127.0.0.1 localhost myimage\n"),
1162 );
1163 let out = compose_virtual_etc_hosts(Some(&rootfs), "");
1164 assert_eq!(out.matches("127.0.0.1 localhost").count(), 1);
1165 assert!(out.contains("::1 localhost"), "v6 loopback should be injected: {out}");
1166 let _ = std::fs::remove_dir_all(&rootfs);
1167 }
1168
1169 #[test]
1170 fn compose_chroot_missing_file_falls_back_to_loopback() {
1171 let rootfs = temp_rootfs_with_hosts("no-file", None);
1174 let out = compose_virtual_etc_hosts(Some(&rootfs), "10.0.0.1 api\n");
1175 assert_eq!(out, "127.0.0.1 localhost\n::1 localhost\n10.0.0.1 api\n");
1176 let _ = std::fs::remove_dir_all(&rootfs);
1177 }
1178
1179 #[test]
1180 fn compose_chroot_strips_inline_comments_when_detecting_loopback() {
1181 let rootfs = temp_rootfs_with_hosts(
1185 "with-comments",
1186 Some("127.0.0.1 # localhost is a comment here\n"),
1187 );
1188 let out = compose_virtual_etc_hosts(Some(&rootfs), "");
1189 assert!(
1191 out.lines().any(|l| l.trim() == "127.0.0.1 localhost"),
1192 "v4 loopback should still be injected: {out}"
1193 );
1194 let _ = std::fs::remove_dir_all(&rootfs);
1195 }
1196
1197 #[test]
1200 fn ipcidr_parse_bare_ipv4_is_host_route() {
1201 let c = IpCidr::parse("1.2.3.4").unwrap();
1202 assert_eq!(c.prefix_len, 32);
1203 assert!(c.contains("1.2.3.4".parse().unwrap()));
1204 assert!(!c.contains("1.2.3.5".parse().unwrap()));
1205 }
1206
1207 #[test]
1208 fn ipcidr_parse_ipv4_range_contains() {
1209 let c = IpCidr::parse("10.0.0.0/8").unwrap();
1210 assert!(c.contains("10.3.7.9".parse().unwrap()));
1211 assert!(!c.contains("11.0.0.1".parse().unwrap()));
1212 }
1213
1214 #[test]
1215 fn ipcidr_parse_ipv6_range_contains() {
1216 let c = IpCidr::parse("fc00::/7").unwrap();
1217 assert!(c.contains("fd00::1".parse().unwrap()));
1218 assert!(!c.contains("2001:db8::1".parse().unwrap()));
1219 }
1220
1221 #[test]
1222 fn ipcidr_zero_prefix_matches_all_same_family() {
1223 let c = IpCidr::parse("0.0.0.0/0").unwrap();
1224 assert!(c.contains("8.8.8.8".parse().unwrap()));
1225 assert!(!c.contains("::1".parse().unwrap())); }
1227
1228 #[test]
1229 fn ipcidr_rejects_hostname() {
1230 assert!(IpCidr::parse("example.com").is_err());
1231 }
1232
1233 #[test]
1234 fn ipcidr_rejects_oversized_prefix() {
1235 assert!(IpCidr::parse("10.0.0.0/33").is_err());
1236 assert!(IpCidr::parse("fc00::/129").is_err());
1237 }
1238
1239 #[test]
1242 fn netdeny_bare_cidr_is_all_ports_tcp_and_udp() {
1243 let rule = deny_pair("10.0.0.0/8");
1246 assert!(matches!(rule.target, NetTarget::Cidr(_)));
1247 assert!(rule.all_ports);
1248 }
1249
1250 #[test]
1251 fn netdeny_bare_ip_is_host_route_all_ports() {
1252 let rule = deny_pair("169.254.169.254");
1253 match &rule.target {
1254 NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 32),
1255 _ => panic!("expected cidr"),
1256 }
1257 assert!(rule.all_ports);
1258 }
1259
1260 #[test]
1261 fn netdeny_cidr_with_port() {
1262 let rule = deny_pair("10.0.0.0/8:443");
1263 assert_eq!(rule.ports, vec![443]);
1264 assert!(!rule.all_ports);
1265 }
1266
1267 #[test]
1268 fn netdeny_any_ip_port() {
1269 let rule = deny_pair(":25");
1270 assert!(matches!(rule.target, NetTarget::AnyIp));
1271 assert_eq!(rule.ports, vec![25]);
1272 }
1273
1274 #[test]
1275 fn netdeny_udp_scheme() {
1276 let rule = deny_one("udp://192.168.0.0/16:53");
1277 assert_eq!(rule.protocol, Protocol::Udp);
1278 assert_eq!(rule.ports, vec![53]);
1279 }
1280
1281 #[test]
1282 fn netdeny_ipv6_bracket_port() {
1283 let rule = deny_pair("[::1]:443");
1284 assert_eq!(rule.ports, vec![443]);
1285 match &rule.target {
1286 NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128),
1287 _ => panic!("expected cidr"),
1288 }
1289 }
1290
1291 #[test]
1292 fn netdeny_rejects_hostname() {
1293 assert!(NetRule::parse_deny("evil.com:443").is_err());
1294 assert!(NetRule::parse_deny("evil.com").is_err());
1295 }
1296
1297 #[test]
1298 fn netdeny_bare_ipv6_address_all_ports() {
1299 let rule = deny_pair("::1");
1300 assert!(rule.all_ports);
1301 match &rule.target {
1302 NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128),
1303 _ => panic!("expected cidr"),
1304 }
1305 }
1306
1307 #[test]
1308 fn netdeny_bare_ipv6_cidr_all_ports() {
1309 let rule = deny_pair("fc00::/7");
1310 assert!(rule.all_ports);
1311 let ula: std::net::IpAddr = "fd00::1".parse().unwrap();
1312 assert!(matches!(&rule.target, NetTarget::Cidr(c) if c.contains(ula)));
1313 }
1314
1315 #[test]
1316 fn netdeny_empty_icmp_body_is_rejected() {
1317 assert!(NetRule::parse_deny("icmp://").is_err());
1318 }
1319
1320 #[test]
1321 fn netdeny_bare_star_is_any_ip_all_ports() {
1322 let rule = deny_pair("*");
1325 assert!(matches!(rule.target, NetTarget::AnyIp));
1326 assert!(rule.all_ports);
1327 assert!(rule.ports.is_empty());
1328 }
1329
1330 #[test]
1331 fn netdeny_udp_bare_star_all_ports() {
1332 let rule = deny_one("udp://*");
1333 assert_eq!(rule.protocol, Protocol::Udp);
1334 assert!(matches!(rule.target, NetTarget::AnyIp));
1335 assert!(rule.all_ports);
1336 }
1337
1338 #[test]
1339 fn netdeny_empty_spec_rejected() {
1340 assert!(NetRule::parse_deny("").is_err());
1342 assert!(NetRule::parse_deny("udp://").is_err());
1343 }
1344
1345 #[test]
1348 fn resolve_net_deny_schemeless_covers_tcp_and_udp() {
1349 let rules = NetRule::parse_deny("10.0.0.0/8").unwrap();
1350 let set = resolve_net_deny(&rules);
1351 assert!(!set.tcp.allows("10.0.0.1".parse().unwrap(), 443));
1354 assert!(!set.udp.allows("10.0.0.1".parse().unwrap(), 443));
1355 assert!(set.icmp.allows("10.0.0.1".parse().unwrap(), 0));
1356 }
1357
1358 #[test]
1359 fn resolve_net_deny_groups_per_protocol() {
1360 let rules = NetRule::parse_deny("tcp://10.0.0.0/8").unwrap();
1361 let set = resolve_net_deny(&rules);
1362 assert!(!set.tcp.allows("10.0.0.1".parse().unwrap(), 443));
1364 assert!(set.udp.allows("10.0.0.1".parse().unwrap(), 443));
1365 }
1366
1367 #[test]
1368 fn resolve_net_deny_any_ip_port() {
1369 let rules = NetRule::parse_deny(":25").unwrap();
1370 let set = resolve_net_deny(&rules);
1371 for policy in [&set.tcp, &set.udp] {
1372 assert!(!policy.allows("8.8.8.8".parse().unwrap(), 25));
1373 assert!(policy.allows("8.8.8.8".parse().unwrap(), 80));
1374 }
1375 }
1376
1377 #[tokio::test]
1378 async fn resolve_net_allow_schemeless_star_unrestricts_tcp_and_udp() {
1379 let rules = NetRule::parse_allow("*").unwrap();
1382 let resolved = resolve_net_allow(&rules).await.unwrap();
1383 assert!(resolved.tcp.any_ip_all_ports);
1384 assert!(resolved.udp.any_ip_all_ports);
1385 assert!(!resolved.icmp.any_ip_all_ports);
1386 }
1387
1388 #[tokio::test]
1389 async fn resolve_net_allow_explicit_tcp_scheme_leaves_udp_closed() {
1390 let rules = NetRule::parse_allow("tcp://*").unwrap();
1391 let resolved = resolve_net_allow(&rules).await.unwrap();
1392 assert!(resolved.tcp.any_ip_all_ports);
1393 assert!(!resolved.udp.any_ip_all_ports);
1394 assert!(resolved.udp.any_ip_ports.is_empty());
1395 assert!(resolved.udp.cidrs.is_empty());
1396 }
1397
1398 #[tokio::test]
1399 async fn resolve_net_allow_schemeless_port_populates_both_sets() {
1400 let rules = NetRule::parse_allow(":53").unwrap();
1403 let resolved = resolve_net_allow(&rules).await.unwrap();
1404 assert!(resolved.tcp.any_ip_ports.contains(&53));
1405 assert!(resolved.udp.any_ip_ports.contains(&53));
1406 assert!(!resolved.tcp.any_ip_all_ports);
1407 assert!(!resolved.udp.any_ip_all_ports);
1408 }
1409
1410 #[tokio::test]
1411 async fn resolve_net_allow_schemeless_host_resolves_once() {
1412 let rules = NetRule::parse_allow("localhost:443").unwrap();
1417 let resolved = resolve_net_allow(&rules).await.unwrap();
1418 let tcp_ips: HashSet<&IpAddr> = resolved.tcp.per_ip.keys().collect();
1419 let udp_ips: HashSet<&IpAddr> = resolved.udp.per_ip.keys().collect();
1420 assert_eq!(tcp_ips, udp_ips);
1421 let lines: Vec<&str> = resolved.concrete_host_entries.lines().collect();
1422 let unique: HashSet<&str> = lines.iter().copied().collect();
1423 assert_eq!(lines.len(), unique.len(), "duplicate hosts lines: {lines:?}");
1424 }
1425}