Skip to main content

sandlock_core/network/
rules.rs

1// Network policy rules: `--net-allow` / `--net-deny` parsing, DNS
2// resolution to the runtime allow/deny sets, and the virtual /etc/hosts
3// composition. No syscall handling lives here; the handlers consume the
4// resolved sets through `SupervisorCtx`.
5
6use std::collections::{HashMap, HashSet};
7use std::io;
8use std::net::IpAddr;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::SandboxError;
13
14/// An IPv4 or IPv6 address with a prefix length, used by `--net-deny`
15/// to match destination IPs by exact address (`/32`, `/128`) or by range.
16#[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    /// Parse `addr` or `addr/prefix`. A bare address becomes a host route
24    /// (`/32` for IPv4, `/128` for IPv6). Hostnames are rejected: the
25    /// address part must parse as a literal IP.
26    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    /// True iff this CIDR is a single host (`/32` IPv4 or `/128` IPv6),
56    /// i.e. it came from a bare IP literal rather than a range.
57    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    /// True iff `ip` falls within this network. Different address
65    /// families never match.
66    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    /// A single host renders as the bare address (`1.2.3.4`, `::1`); a
89    /// range keeps its prefix (`10.0.0.0/8`). Inverse of [`IpCidr::parse`].
90    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/// What a `--net-allow` / `--net-deny` rule targets at the IP layer.
100///
101/// `Cidr` covers both a bare IP literal (stored as a `/32` or `/128`) and
102/// an explicit CIDR range. `Host` is a hostname resolved via DNS at sandbox
103/// start; it is only produced for `--net-allow` (deny rejects hostnames).
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub enum NetTarget {
106    /// Any destination IP (the `:port` / `*:port` / `*` form).
107    AnyIp,
108    /// A literal IP or CIDR range. Matched by containment, no DNS.
109    Cidr(IpCidr),
110    /// A hostname, resolved to IPs at sandbox start (allow-only).
111    Host(String),
112}
113
114/// A single `--net-allow` / `--net-deny` rule. Both flags share this
115/// representation and the same grammar; they differ only in whether
116/// hostnames are accepted (`--net-deny` rejects them) and in how the
117/// resolved rule is enforced (allowlist vs denylist).
118///
119/// A rule always names exactly one concrete protocol: a scheme-less
120/// spec expands to a TCP rule plus a UDP rule at parse time.
121#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
122pub struct NetRule {
123    /// L4 protocol this rule applies to.
124    pub protocol: Protocol,
125    /// What the rule targets at the IP layer.
126    pub target: NetTarget,
127    /// Permitted/denied ports. Empty when `all_ports` is true and always
128    /// empty for `Protocol::Icmp`.
129    pub ports: Vec<u16>,
130    /// "Any port" (bare target with no `:port`, or the `*` port token).
131    #[serde(default)]
132    pub all_ports: bool,
133}
134
135/// `--net-allow` and `--net-deny` rules are the same shape; the aliases
136/// document intent at call sites and field declarations.
137pub type NetAllow = NetRule;
138pub type NetDeny = NetRule;
139
140impl NetRule {
141    /// Parse a `--net-allow` spec into rules. Hostnames are accepted and
142    /// resolved to IPs at sandbox start. Grammar (shared with `--net-deny`):
143    ///
144    /// - `host` / `<ip>` / `<cidr>` / `*` -- all ports (port optional; `*`
145    ///   targets any IP).
146    /// - `host:<port[,port,...]>` / `<ip>:<port>` / `<cidr>:*` / `:port`.
147    /// - `[<ipv6|ipv6cidr>]:<port>` -- bracketed IPv6 with a port (a bare
148    ///   `addr:port` string is itself a valid IPv6 address, so the port
149    ///   form needs brackets).
150    /// - `tcp://...` / `udp://...` / `icmp://...` schemes (icmp: no port).
151    ///
152    /// A spec with no scheme applies to both TCP and UDP and expands to
153    /// one rule per protocol; a scheme restricts the spec to that one
154    /// protocol. ICMP is never implied: it always needs `icmp://`.
155    pub fn parse_allow(spec: &str) -> Result<Vec<NetRule>, SandboxError> {
156        Self::parse_spec(spec, "--net-allow", true)
157    }
158
159    /// Parse a `--net-deny` spec into rules. Identical grammar to
160    /// [`parse_allow`](Self::parse_allow), except hostnames are rejected
161    /// (the target must be a literal IP/CIDR or `*`); use `--http-deny`
162    /// for domain blocking.
163    pub fn parse_deny(spec: &str) -> Result<Vec<NetDeny>, SandboxError> {
164        Self::parse_spec(spec, "--net-deny", false)
165    }
166
167    /// Shared grammar for both flags. `label` selects the error prefix and
168    /// `allow_hosts` whether non-IP targets are accepted (allow) or
169    /// rejected (deny).
170    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        // ICMP carries no port: the whole body is the target.
185        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            // Reject an explicit port. IPv6 literals/CIDRs also contain
193            // `:`, so only flag a `:` that isn't part of a valid IP/CIDR.
194            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        // 1. Bracketed IPv6 with a port: `[addr]:ports`.
209        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            // An empty body must not silently mean "everything"; require
217            // an explicit `*` for the any-IP target.
218            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            // 2. Whole body is an IP/CIDR with no port -> all ports. Trying
224            //    `IpCidr::parse` first is what makes bare IPv6 (`::1`) and
225            //    IPv6 CIDRs (`fc00::/7`) work despite containing colons.
226            (NetTarget::Cidr(cidr), Vec::new(), true)
227        } else {
228            // 3. `target[:ports]` where target is an IP/CIDR, hostname, `*`,
229            //    or empty. The port suffix is optional: a target with no
230            //    `:port` covers all ports, mirroring the bare-target form.
231            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        // A scheme pins one protocol; no scheme covers both port-carrying
244        // protocols, so the spec expands to a TCP rule plus a UDP rule.
245        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
261/// Parse a rule target: `*` / empty -> any IP, an IP/CIDR literal ->
262/// `Cidr`, otherwise a hostname (`Host`) when `allow_hosts`, else an error.
263fn parse_target(s: &str, label: &str, allow_hosts: bool) -> Result<NetTarget, SandboxError> {
264    match s {
265        "" | "*" => Ok(NetTarget::AnyIp),
266        // A `/` signals CIDR intent: parse strictly so a bad prefix is a
267        // clear error rather than being misread as a hostname.
268        _ 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
287/// Parse a port suffix. `*` means all ports; mixing `*` with concrete
288/// ports, port 0, and an empty list are all rejected.
289fn 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/// L4 protocol that a `NetAllow` rule applies to.
325///
326/// A rule with no scheme (the bare `host:port` form) covers TCP and
327/// UDP: it expands to one rule per protocol at parse time. `Icmp`
328/// requires an explicit `icmp://` scheme.
329///
330/// `Icmp` is the kernel's unprivileged ping socket
331/// (`SOCK_DGRAM + IPPROTO_ICMP{,V6}`), gated by `ping_group_range` —
332/// destinations are filterable per host. Sandlock does not expose raw
333/// ICMP (`SOCK_RAW + IPPROTO_ICMP`): destination filtering at `sendto`
334/// would lie because raw sockets let the agent craft the IP header,
335/// and packet-crafting capabilities aren't part of the XOA threat
336/// model. Workloads that genuinely need raw ICMP should run outside
337/// sandlock or rely on the host's `ping_group_range` for the dgram
338/// path instead.
339#[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
358// ============================================================
359// resolve_net_allow — resolve --net-allow rules to runtime allowlist
360// ============================================================
361
362/// Resolved form of `Policy::net_allow`, ready for the on-behalf path.
363pub struct ResolvedNetAllow {
364    /// Per-IP port rules (each concrete-host entry resolves to one or
365    /// more IPs). An IP appearing here with an empty port set means
366    /// "all ports for this IP" (from a `host:*` rule).
367    pub per_ip: HashMap<IpAddr, HashSet<u16>>,
368    /// IPs permitted on every port (from `host:*` rules after host
369    /// resolution). The on-behalf path treats these the same as
370    /// `PortAllow::Any` — the entry in `per_ip` is kept as a
371    /// placeholder for diagnostic / `/etc/hosts` purposes.
372    pub per_ip_all_ports: HashSet<IpAddr>,
373    /// IP/CIDR-literal targets, matched by containment with no DNS (an
374    /// exact IP literal is a `/32` or `/128`). Each carries the ports
375    /// permitted to that range (`PortAllow::Any` for all-ports rules).
376    pub cidrs: Vec<(IpCidr, crate::seccomp::notif::PortAllow)>,
377    /// Ports permitted to any IP (the `:port` form).
378    pub any_ip_ports: HashSet<u16>,
379    /// Any-host any-port wildcard (`:*` / `*:*`, or `icmp://*`). When
380    /// true, the per-protocol policy becomes `Unrestricted` and the
381    /// on-behalf check is bypassed for that protocol.
382    pub any_ip_all_ports: bool,
383}
384
385/// Per-protocol resolved allowlists. Each protocol gets its own
386/// `ResolvedNetAllow`; the on-behalf path picks the right one based on
387/// the dup'd fd's `SO_PROTOCOL`. `etc_hosts` is shared across all
388/// protocols (the synthetic file maps every concrete host that appears
389/// in any rule).
390pub struct ResolvedNetAllowSet {
391    pub tcp: ResolvedNetAllow,
392    pub udp: ResolvedNetAllow,
393    pub icmp: ResolvedNetAllow,
394    /// `<ip> <hostname>\n` lines for every unique concrete host across
395    /// every protocol, in first-appearance order (each host resolves
396    /// once, shared by all its rules). Empty when no concrete-host
397    /// rules are present. Combined with the loopback base (or, in chroot
398    /// mode, the image's `/etc/hosts`) by [`compose_virtual_etc_hosts`]
399    /// to build the synthetic file served to the sandbox.
400    pub concrete_host_entries: String,
401}
402
403/// Resolve `--net-allow` rules into per-protocol runtime allowlists.
404///
405/// Rules are grouped by `Protocol` and each group is resolved
406/// independently. ICMP rules carry no ports, so the resulting ICMP
407/// `ResolvedNetAllow` always has empty `any_ip_ports` / per-IP port
408/// sets — the on-behalf check routes ICMP through the IP-only path
409/// (PortAllow::Any). A `*` host on ICMP becomes `any_ip_all_ports`,
410/// which the handler reads as "no destination check."
411pub async fn resolve_net_allow(
412    rules: &[NetAllow],
413) -> io::Result<ResolvedNetAllowSet> {
414    use crate::seccomp::notif::PortAllow;
415
416    // Resolve each unique hostname once, up front. A scheme-less spec
417    // expands to a TCP + UDP rule pair naming the same host; one lookup
418    // per protocol pass could disagree under DNS round-robin, leaving
419    // the two allowlists pinned to different IPs, and would emit the
420    // `/etc/hosts` line once per pass.
421    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                        // ICMP rules never carry ports, so a wildcard-host
455                        // ICMP rule (`icmp://*`) means "any destination."
456                        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                    // IP/CIDR literals are matched by containment with no
465                    // DNS, exactly like `--net-deny` targets.
466                    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
506/// Per-protocol resolved deny policies, ready for `NetworkState`.
507pub 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
513/// Resolve `--net-deny` rules into per-protocol `DenyList` policies.
514/// A protocol with no deny rules stays `Unrestricted` (allow-all).
515pub 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                // `--net-deny` rejects hostnames at parse time, so a deny
545                // rule never carries a `Host` target.
546                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
568/// Compose the synthetic `/etc/hosts` served to the sandbox.
569///
570/// - **No chroot**: emit the fixed loopback base
571///   (`127.0.0.1 localhost\n::1 localhost\n`) followed by the
572///   concrete-host entries from [`resolve_net_allow`]. The sandbox sees
573///   the same baseline regardless of what the host's on-disk file says.
574/// - **With chroot**: read `<chroot>/etc/hosts` and use it as the base
575///   (an image that bakes in private-registry entries or similar keeps
576///   them). Inject loopback entries only for any localhost family the
577///   image doesn't already cover — never both, so we don't duplicate
578///   what the image already has. Concrete-host entries are still
579///   appended on top.
580///
581/// If a chroot is set but `<chroot>/etc/hosts` is unreadable (absent,
582/// permission denied, etc.), fall back to the bare loopback base — the
583/// sandbox always sees a usable hosts file.
584pub 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                // Strip an inline `#` comment before tokenizing — the
596                // hosts(5) format treats everything after `#` as a comment.
597                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    /// Parse an allow spec that carries an explicit scheme: exactly one
632    /// rule comes back.
633    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    /// Parse a scheme-less allow spec: it expands to a TCP + UDP pair
640    /// sharing target/ports. Returns the TCP rule after checking the
641    /// pair is consistent.
642    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    // --- NetAllow::parse tests ---
669
670    #[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        // `host:80,*` and `host:*,80` are both ambiguous: the user
726        // either meant "any port" (wildcard wins) or "ports 80 plus
727        // some weird placeholder". Refuse and force a clean spec.
728        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        // No port suffix means "all ports" (port optional), symmetric
749        // with the `host:*` form.
750        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        // CIDR ranges are now first-class in --net-allow (matched by
773        // containment, no DNS), symmetric with --net-deny.
774        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        // Bare IPv6 literal (previously mis-split on its colons).
784        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        // Bracketed IPv6 with a port.
788        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        // IPv6 CIDR.
792        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        // A CIDR / IP-literal target resolves into `cidrs` directly, with
800        // no DNS lookup and no `per_ip` / `/etc/hosts` entry.
801        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        // `*,*` collapses to a single wildcard — neither token contributes
814        // a concrete port, so the rule remains "any port".
815        let r = allow_pair(":*,*");
816        assert!(r.all_ports);
817        assert!(r.ports.is_empty());
818    }
819
820    // --- Protocol scheme prefix tests ---
821
822    #[test]
823    fn netallow_schemeless_expands_to_tcp_and_udp() {
824        // Issue #132: a spec with no scheme covers both port-carrying
825        // protocols. `allow_pair` asserts the TCP+UDP pair shape.
826        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        // An IP literal becomes a single-host CIDR target (no DNS).
843        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        // The "any UDP" gate, equivalent to the old `allow_udp = true`.
851        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        // ICMP carries no ports, so the rule is "all ports" by convention.
864        assert!(r.all_ports);
865    }
866
867    #[test]
868    fn netallow_icmp_wildcard() {
869        // The "any ICMP echo" gate, equivalent to the old
870        // `allow_icmp = true` for the SOCK_DGRAM path.
871        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        // ICMP has no port — `:port` is meaningless and refused
879        // explicitly so users can't write a rule that doesn't do what
880        // they think.
881        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        // Including `icmp-raw` — sandlock does not expose raw ICMP, so
894        // the scheme is unknown rather than a special-case error.
895        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        // No concrete-host rules → no resolved-entry lines.
909        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        // localhost should resolve to at least one loopback addr; only
922        // the TCP set has entries.
923        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        // The resolved entry (`<ip> localhost`) surfaces in concrete_host_entries.
931        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        // Any-IP rule has no concrete host, so no resolved-entry line.
947        assert!(resolved.concrete_host_entries.is_empty());
948    }
949
950    #[tokio::test]
951    async fn test_resolve_net_allow_any_ip_all_ports() {
952        // `:*` — fully unrestricted egress, TCP-only.
953        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        // UDP/ICMP unaffected by a TCP rule.
965        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        // `localhost:*` — every port to localhost only, TCP.
972        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        // Wildcard rule alongside concrete: wildcard sets the global
993        // any-host any-port flag for TCP; concrete rule still resolves
994        // into per_ip (the runtime layer chooses Unrestricted, ignoring
995        // the concrete entries).
996        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    // ============================================================
1016    // Per-protocol resolution — UDP / ICMP slices stay isolated
1017    // ============================================================
1018
1019    #[tokio::test]
1020    async fn test_resolve_per_protocol_isolation() {
1021        // A UDP rule should not appear in the TCP set, and vice versa.
1022        // This is the property Phase 2 relies on for protocol routing.
1023        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        // Cross-contamination check: TCP per_ip ports must not contain 53;
1047        // UDP must not contain 443.
1048        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        // ICMP rules carry no ports; concrete hosts go into per_ip with
1057        // PortAllow::Any-style empty port set, plus per_ip_all_ports.
1058        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        // TCP/UDP unaffected.
1075        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        // `icmp://*` — any ICMP destination.
1082        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    // ============================================================
1094    // compose_virtual_etc_hosts — synthetic /etc/hosts assembly
1095    // ============================================================
1096
1097    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        // Default path — no chroot, no concrete-host rules → the same
1115        // fixed loopback view we promise every sandbox.
1116        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        // Image ships an entry of its own but no localhost mapping; the
1129        // shim must keep the image's content and inject both loopback
1130        // entries on top so the always-on guarantee still holds.
1131        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        // Image already has both loopback entries — don't append duplicates.
1145        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        // Image has v4 but no v6 localhost — inject only v6, leave v4 alone.
1159        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        // Chroot exists but has no /etc/hosts — fall back to the bare
1172        // loopback base so the sandbox always sees a usable file.
1173        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        // hosts(5) treats `#` as a comment-start; the loopback-presence
1182        // check must respect it (otherwise an image line like
1183        // `127.0.0.1 # localhost` would be falsely treated as covering v4).
1184        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        // Real `127.0.0.1 localhost` line must still be injected.
1190        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    // --- IpCidr tests ---
1198
1199    #[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())); // family mismatch
1226    }
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    // --- NetDeny::parse tests ---
1240
1241    #[test]
1242    fn netdeny_bare_cidr_is_all_ports_tcp_and_udp() {
1243        // A scheme-less deny covers both protocols (issue #132): a
1244        // blocked CIDR must not stay reachable over UDP.
1245        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        // `*` with no port is the any-IP, all-ports form (port optional,
1323        // symmetric with a bare IP/CIDR), covering TCP and UDP.
1324        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        // An empty body must not silently mean "deny everything".
1341        assert!(NetRule::parse_deny("").is_err());
1342        assert!(NetRule::parse_deny("udp://").is_err());
1343    }
1344
1345    // --- resolve_net_deny tests ---
1346
1347    #[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        // A scheme-less deny closes both L4 paths to the CIDR; ICMP has
1352        // no rule and stays allow-all.
1353        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        // An explicit tcp:// deny leaves UDP/ICMP unaffected (allow-all).
1363        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        // Issue #132: `--net-allow '*'` alone now grants full TCP and
1380        // UDP egress; ICMP still requires an explicit `icmp://` rule.
1381        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        // `:53` grants port 53 over both TCP and UDP, so plain DNS
1401        // works without a separate `udp://` rule.
1402        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        // A scheme-less host spec expands to a TCP + UDP rule pair; the
1413        // host must resolve once and share the IP set (independent
1414        // lookups could disagree under DNS round-robin) and each
1415        // /etc/hosts line must appear exactly once.
1416        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}