Skip to main content

sandlock_core/
network.rs

1// Network policy and control handlers — IP allowlist enforcement via seccomp notification.
2//
3// Intercepts connect/sendto/sendmsg syscalls, extracts the destination IP from
4// the child's memory, and checks it against an allowlist of resolved IPs.
5
6use std::collections::{HashMap, HashSet};
7use std::io;
8use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
9use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd};
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::SandboxError;
15use crate::seccomp::ctx::SupervisorCtx;
16use crate::seccomp::notif::{read_child_mem, write_child_mem, NotifAction};
17use crate::sys::structs::{SeccompNotif, AF_INET, AF_INET6, ECONNREFUSED};
18
19/// Maximum buffer size for sendto/sendmsg on-behalf operations (64 MiB).
20/// Prevents a sandboxed process from triggering OOM in the supervisor.
21const MAX_SEND_BUF: usize = 64 << 20;
22
23/// An IPv4 or IPv6 address with a prefix length, used by `--net-deny`
24/// to match destination IPs by exact address (`/32`, `/128`) or by range.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub struct IpCidr {
27    pub addr: IpAddr,
28    pub prefix_len: u8,
29}
30
31impl IpCidr {
32    /// Parse `addr` or `addr/prefix`. A bare address becomes a host route
33    /// (`/32` for IPv4, `/128` for IPv6). Hostnames are rejected: the
34    /// address part must parse as a literal IP.
35    pub fn parse(s: &str) -> Result<Self, SandboxError> {
36        let (addr_str, prefix) = match s.split_once('/') {
37            Some((a, p)) => {
38                let len: u8 = p.parse().map_err(|_| {
39                    SandboxError::Invalid(format!("invalid prefix length in `{}`", s))
40                })?;
41                (a, Some(len))
42            }
43            None => (s, None),
44        };
45        let addr: IpAddr = addr_str.parse().map_err(|_| {
46            SandboxError::Invalid(format!("`{}` is not a valid IP address", s))
47        })?;
48        let max = match addr {
49            IpAddr::V4(_) => 32u8,
50            IpAddr::V6(_) => 128u8,
51        };
52        let prefix_len = prefix.unwrap_or(max);
53        if prefix_len > max {
54            return Err(SandboxError::Invalid(format!(
55                "prefix /{} too large for {} in `{}`",
56                prefix_len,
57                if max == 32 { "IPv4" } else { "IPv6" },
58                s
59            )));
60        }
61        Ok(IpCidr { addr, prefix_len })
62    }
63
64    /// True iff this CIDR is a single host (`/32` IPv4 or `/128` IPv6),
65    /// i.e. it came from a bare IP literal rather than a range.
66    pub fn is_single_host(&self) -> bool {
67        match self.addr {
68            IpAddr::V4(_) => self.prefix_len == 32,
69            IpAddr::V6(_) => self.prefix_len == 128,
70        }
71    }
72
73    /// True iff `ip` falls within this network. Different address
74    /// families never match.
75    pub fn contains(&self, ip: IpAddr) -> bool {
76        match (self.addr, ip) {
77            (IpAddr::V4(net), IpAddr::V4(ip)) => {
78                if self.prefix_len == 0 {
79                    return true;
80                }
81                let mask = u32::MAX << (32 - self.prefix_len);
82                (u32::from(net) & mask) == (u32::from(ip) & mask)
83            }
84            (IpAddr::V6(net), IpAddr::V6(ip)) => {
85                if self.prefix_len == 0 {
86                    return true;
87                }
88                let mask = u128::MAX << (128 - self.prefix_len);
89                (u128::from(net) & mask) == (u128::from(ip) & mask)
90            }
91            _ => false,
92        }
93    }
94}
95
96impl std::fmt::Display for IpCidr {
97    /// A single host renders as the bare address (`1.2.3.4`, `::1`); a
98    /// range keeps its prefix (`10.0.0.0/8`). Inverse of [`IpCidr::parse`].
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        if self.is_single_host() {
101            write!(f, "{}", self.addr)
102        } else {
103            write!(f, "{}/{}", self.addr, self.prefix_len)
104        }
105    }
106}
107
108/// What a `--net-allow` / `--net-deny` rule targets at the IP layer.
109///
110/// `Cidr` covers both a bare IP literal (stored as a `/32` or `/128`) and
111/// an explicit CIDR range. `Host` is a hostname resolved via DNS at sandbox
112/// start; it is only produced for `--net-allow` (deny rejects hostnames).
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub enum NetTarget {
115    /// Any destination IP (the `:port` / `*:port` / `*` form).
116    AnyIp,
117    /// A literal IP or CIDR range. Matched by containment, no DNS.
118    Cidr(IpCidr),
119    /// A hostname, resolved to IPs at sandbox start (allow-only).
120    Host(String),
121}
122
123/// A single `--net-allow` / `--net-deny` rule. Both flags share this
124/// representation and the same grammar; they differ only in whether
125/// hostnames are accepted (`--net-deny` rejects them) and in how the
126/// resolved rule is enforced (allowlist vs denylist).
127#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
128pub struct NetRule {
129    /// L4 protocol this rule applies to.
130    #[serde(default = "default_protocol_tcp")]
131    pub protocol: Protocol,
132    /// What the rule targets at the IP layer.
133    pub target: NetTarget,
134    /// Permitted/denied ports. Empty when `all_ports` is true and always
135    /// empty for `Protocol::Icmp`.
136    pub ports: Vec<u16>,
137    /// "Any port" (bare target with no `:port`, or the `*` port token).
138    #[serde(default)]
139    pub all_ports: bool,
140}
141
142/// `--net-allow` and `--net-deny` rules are the same shape; the aliases
143/// document intent at call sites and field declarations.
144pub type NetAllow = NetRule;
145pub type NetDeny = NetRule;
146
147fn default_protocol_tcp() -> Protocol {
148    Protocol::Tcp
149}
150
151impl NetRule {
152    /// Parse a `--net-allow` spec into a rule. Hostnames are accepted and
153    /// resolved to IPs at sandbox start. Grammar (shared with `--net-deny`):
154    ///
155    /// - `host` / `<ip>` / `<cidr>` / `*` -- all ports (port optional; `*`
156    ///   targets any IP). TCP is the default scheme.
157    /// - `host:<port[,port,...]>` / `<ip>:<port>` / `<cidr>:*` / `:port`.
158    /// - `[<ipv6|ipv6cidr>]:<port>` -- bracketed IPv6 with a port (a bare
159    ///   `addr:port` string is itself a valid IPv6 address, so the port
160    ///   form needs brackets).
161    /// - `tcp://...` / `udp://...` / `icmp://...` schemes (icmp: no port).
162    pub fn parse_allow(spec: &str) -> Result<NetRule, SandboxError> {
163        Self::parse_spec(spec, "--net-allow", true)
164    }
165
166    /// Parse a `--net-deny` spec into a rule. Identical grammar to
167    /// [`parse_allow`](Self::parse_allow), except hostnames are rejected
168    /// (the target must be a literal IP/CIDR or `*`); use `--http-deny`
169    /// for domain blocking.
170    pub fn parse_deny(spec: &str) -> Result<NetDeny, SandboxError> {
171        Self::parse_spec(spec, "--net-deny", false)
172    }
173
174    /// Shared grammar for both flags. `label` selects the error prefix and
175    /// `allow_hosts` whether non-IP targets are accepted (allow) or
176    /// rejected (deny).
177    fn parse_spec(spec: &str, label: &str, allow_hosts: bool) -> Result<NetRule, SandboxError> {
178        let (protocol, rest) = match spec.split_once("://") {
179            Some((scheme, body)) => {
180                let proto = Protocol::parse(scheme).ok_or_else(|| {
181                    SandboxError::Invalid(format!(
182                        "{}: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)",
183                        label, scheme, spec
184                    ))
185                })?;
186                (proto, body)
187            }
188            None => (Protocol::Tcp, spec),
189        };
190
191        // ICMP carries no port: the whole body is the target.
192        if protocol == Protocol::Icmp {
193            if rest.is_empty() {
194                return Err(SandboxError::Invalid(format!(
195                    "{}: icmp rule needs a host/IP or `*`, got `{}`",
196                    label, spec
197                )));
198            }
199            // Reject an explicit port. IPv6 literals/CIDRs also contain
200            // `:`, so only flag a `:` that isn't part of a valid IP/CIDR.
201            if rest != "*" && IpCidr::parse(rest).is_err() && rest.contains(':') {
202                return Err(SandboxError::Invalid(format!(
203                    "{}: icmp rule takes no port, got `{}`",
204                    label, spec
205                )));
206            }
207            return Ok(NetRule {
208                protocol,
209                target: parse_target(rest, label, allow_hosts)?,
210                ports: Vec::new(),
211                all_ports: true,
212            });
213        }
214
215        // 1. Bracketed IPv6 with a port: `[addr]:ports`.
216        if let Some(stripped) = rest.strip_prefix('[') {
217            let (inside, port_part) = stripped.rsplit_once("]:").ok_or_else(|| {
218                SandboxError::Invalid(format!("{}: malformed bracketed address in `{}`", label, spec))
219            })?;
220            let (ports, all_ports) = parse_ports(port_part, label, spec)?;
221            return Ok(NetRule {
222                protocol,
223                target: NetTarget::Cidr(IpCidr::parse(inside)?),
224                ports,
225                all_ports,
226            });
227        }
228
229        // An empty body must not silently mean "everything"; require an
230        // explicit `*` for the any-IP target.
231        if rest.is_empty() {
232            return Err(SandboxError::Invalid(format!(
233                "{}: empty rule in `{}` (use `*` for any host)",
234                label, spec
235            )));
236        }
237
238        // 2. Whole body is an IP/CIDR with no port -> all ports. Trying
239        //    `IpCidr::parse` first is what makes bare IPv6 (`::1`) and IPv6
240        //    CIDRs (`fc00::/7`) work despite containing colons.
241        if let Ok(cidr) = IpCidr::parse(rest) {
242            return Ok(NetRule {
243                protocol,
244                target: NetTarget::Cidr(cidr),
245                ports: Vec::new(),
246                all_ports: true,
247            });
248        }
249
250        // 3. `target[:ports]` where target is an IP/CIDR, hostname, `*`, or
251        //    empty. The port suffix is optional: a target with no `:port`
252        //    covers all ports, mirroring the bare-target form above.
253        let (host_part, port_part) = match rest.rsplit_once(':') {
254            Some((h, p)) => (h, Some(p)),
255            None => (rest, None),
256        };
257        let target = parse_target(host_part, label, allow_hosts)?;
258        let (ports, all_ports) = match port_part {
259            Some(p) => parse_ports(p, label, spec)?,
260            None => (Vec::new(), true),
261        };
262        Ok(NetRule {
263            protocol,
264            target,
265            ports,
266            all_ports,
267        })
268    }
269}
270
271/// Parse a rule target: `*` / empty -> any IP, an IP/CIDR literal ->
272/// `Cidr`, otherwise a hostname (`Host`) when `allow_hosts`, else an error.
273fn parse_target(s: &str, label: &str, allow_hosts: bool) -> Result<NetTarget, SandboxError> {
274    match s {
275        "" | "*" => Ok(NetTarget::AnyIp),
276        // A `/` signals CIDR intent: parse strictly so a bad prefix is a
277        // clear error rather than being misread as a hostname.
278        _ if s.contains('/') => Ok(NetTarget::Cidr(
279            IpCidr::parse(s).map_err(|e| SandboxError::Invalid(format!("{}: {}", label, e)))?,
280        )),
281        _ => {
282            if let Ok(cidr) = IpCidr::parse(s) {
283                Ok(NetTarget::Cidr(cidr))
284            } else if allow_hosts {
285                Ok(NetTarget::Host(s.to_string()))
286            } else {
287                Err(SandboxError::Invalid(format!(
288                    "{}: `{}` is not an IP or CIDR (hostnames are not allowed; \
289                     use --http-deny for domains)",
290                    label, s
291                )))
292            }
293        }
294    }
295}
296
297/// Parse a port suffix. `*` means all ports; mixing `*` with concrete
298/// ports, port 0, and an empty list are all rejected.
299fn parse_ports(s: &str, label: &str, full: &str) -> Result<(Vec<u16>, bool), SandboxError> {
300    let mut ports = Vec::new();
301    let mut saw_wildcard = false;
302    for p in s.split(',') {
303        let p = p.trim();
304        if p == "*" {
305            saw_wildcard = true;
306            continue;
307        }
308        let n: u16 = p.parse().map_err(|_| {
309            SandboxError::Invalid(format!("{}: invalid port `{}` in `{}`", label, p, full))
310        })?;
311        if n == 0 {
312            return Err(SandboxError::Invalid(format!(
313                "{}: port 0 is not valid in `{}`",
314                label, full
315            )));
316        }
317        ports.push(n);
318    }
319    if saw_wildcard && !ports.is_empty() {
320        return Err(SandboxError::Invalid(format!(
321            "{}: cannot mix `*` with concrete ports in `{}`",
322            label, full
323        )));
324    }
325    if !saw_wildcard && ports.is_empty() {
326        return Err(SandboxError::Invalid(format!(
327            "{}: at least one port required in `{}`",
328            label, full
329        )));
330    }
331    Ok((ports, saw_wildcard))
332}
333
334/// L4 protocol that a `NetAllow` rule applies to.
335///
336/// `Tcp` is the default if a rule has no scheme (the bare `host:port`
337/// form). `Udp` and `Icmp` require an explicit scheme.
338///
339/// `Icmp` is the kernel's unprivileged ping socket
340/// (`SOCK_DGRAM + IPPROTO_ICMP{,V6}`), gated by `ping_group_range` —
341/// destinations are filterable per host. Sandlock does not expose raw
342/// ICMP (`SOCK_RAW + IPPROTO_ICMP`): destination filtering at `sendto`
343/// would lie because raw sockets let the agent craft the IP header,
344/// and packet-crafting capabilities aren't part of the XOA threat
345/// model. Workloads that genuinely need raw ICMP should run outside
346/// sandlock or rely on the host's `ping_group_range` for the dgram
347/// path instead.
348#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
349#[serde(rename_all = "lowercase")]
350pub enum Protocol {
351    Tcp,
352    Udp,
353    Icmp,
354}
355
356impl Protocol {
357    fn parse(s: &str) -> Option<Self> {
358        match s {
359            "tcp" => Some(Protocol::Tcp),
360            "udp" => Some(Protocol::Udp),
361            "icmp" => Some(Protocol::Icmp),
362            _ => None,
363        }
364    }
365}
366
367// ============================================================
368// parse_ip_from_sockaddr — parse IP from a sockaddr byte buffer
369// ============================================================
370
371/// Parse IP address from a sockaddr byte buffer.
372/// Returns None for non-IP families (AF_UNIX etc.) — always allowed.
373fn parse_ip_from_sockaddr(bytes: &[u8]) -> Option<IpAddr> {
374    if bytes.len() < 2 {
375        return None;
376    }
377    let family = u16::from_ne_bytes([bytes[0], bytes[1]]) as u32;
378    match family {
379        f if f == AF_INET => {
380            if bytes.len() < 8 {
381                return None;
382            }
383            Some(IpAddr::V4(Ipv4Addr::new(
384                bytes[4], bytes[5], bytes[6], bytes[7],
385            )))
386        }
387        f if f == AF_INET6 => {
388            if bytes.len() < 24 {
389                return None;
390            }
391            let mut addr_bytes = [0u8; 16];
392            addr_bytes.copy_from_slice(&bytes[8..24]);
393            Some(IpAddr::V6(Ipv6Addr::from(addr_bytes)))
394        }
395        _ => None,
396    }
397}
398
399// ============================================================
400// parse_port_from_sockaddr — parse TCP port from sockaddr bytes
401// ============================================================
402
403/// Parse TCP port from a sockaddr byte buffer.
404/// Returns None for non-IP families (AF_UNIX etc.).
405fn parse_port_from_sockaddr(bytes: &[u8]) -> Option<u16> {
406    if bytes.len() < 4 {
407        return None;
408    }
409    let family = u16::from_ne_bytes([bytes[0], bytes[1]]) as u32;
410    match family {
411        f if f == AF_INET || f == AF_INET6 => {
412            Some(u16::from_be_bytes([bytes[2], bytes[3]]))
413        }
414        _ => None,
415    }
416}
417
418fn set_port_in_sockaddr(bytes: &mut [u8], port: u16) {
419    if bytes.len() >= 4 {
420        let port_bytes = port.to_be_bytes();
421        bytes[2] = port_bytes[0];
422        bytes[3] = port_bytes[1];
423    }
424}
425
426// ============================================================
427// query_socket_protocol — derive the rule Protocol from a fd via getsockopt
428// ============================================================
429
430/// Query `SO_PROTOCOL` on a dup'd socket fd to learn whether to route
431/// the on-behalf check through the TCP, UDP, or ICMP policy.
432///
433/// Returns `None` for protocols sandlock does not gate via `net_allow`
434/// (raw, SCTP, etc.) — the handler treats those as "no rule applies"
435/// which collapses to the default-deny path.
436pub(crate) fn query_socket_protocol(fd: RawFd) -> Option<Protocol> {
437    let mut proto: libc::c_int = 0;
438    let mut len: libc::socklen_t = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
439    let rc = unsafe {
440        libc::getsockopt(
441            fd,
442            libc::SOL_SOCKET,
443            libc::SO_PROTOCOL,
444            &mut proto as *mut _ as *mut libc::c_void,
445            &mut len,
446        )
447    };
448    if rc != 0 {
449        return None;
450    }
451    match proto {
452        libc::IPPROTO_TCP => Some(Protocol::Tcp),
453        libc::IPPROTO_UDP => Some(Protocol::Udp),
454        // IPPROTO_ICMP and IPPROTO_ICMPV6 both route to the ICMP policy
455        // (the policy doesn't distinguish IP versions; the rule's
456        // resolved IP set already covers both via DNS).
457        libc::IPPROTO_ICMP | libc::IPPROTO_ICMPV6 => Some(Protocol::Icmp),
458        _ => None,
459    }
460}
461
462// ============================================================
463// connect_on_behalf — perform connect() on behalf of the child (TOCTOU-safe)
464// ============================================================
465
466/// Perform connect() on behalf of the child process (TOCTOU-safe).
467///
468/// 1. Copy sockaddr from child memory (our copy — immune to TOCTOU)
469/// 2. Check IP against allowlist on our copy
470/// 3. Duplicate child's socket fd via pidfd_getfd
471/// 4. connect() in supervisor with our validated sockaddr
472/// 5. Return result to child
473async fn connect_on_behalf(
474    notif: &SeccompNotif,
475    ctx: &Arc<SupervisorCtx>,
476    notif_fd: RawFd,
477) -> NotifAction {
478    let args = &notif.data.args;
479    let sockfd = args[0] as i32;
480    let addr_ptr = args[1];
481    let addr_len = args[2] as u32;
482
483    // 1. Copy sockaddr from child memory
484    let addr_bytes =
485        match read_child_mem(notif_fd, notif.id, notif.pid, addr_ptr, addr_len as usize) {
486            Ok(b) => b,
487            Err(_) => return NotifAction::Errno(libc::EIO),
488        };
489
490    // 2. Check destination against the per-protocol endpoint allowlist.
491    // The dup we'd need anyway for the on-behalf connect doubles as
492    // our SO_PROTOCOL probe — one pidfd_getfd, one getsockopt. The
493    // per-protocol policy is keyed on whether the socket is TCP / UDP
494    // / kernel ping (ICMP). Unknown protocol (raw, SCTP, etc.) fails
495    // closed: the BPF should have prevented socket creation, so
496    // reaching here with one is an unexpected case worth refusing.
497    if let Some(ip) = parse_ip_from_sockaddr(&addr_bytes) {
498        // Same invariant as the sendto/sendmsg handlers above: `connect()` is
499        // trapped whenever the named-`AF_UNIX` gate is on (any fs grant), for
500        // every address family, but with no network destination policy there
501        // is nothing to enforce on an IP destination. Return it to the kernel
502        // so the child's own Landlock `CONNECT_TCP` rules govern it — handling
503        // it on-behalf in the unconfined supervisor would bypass that decision
504        // (an empty `net_allow` deny-all would silently permit egress). See
505        // `NotifPolicy::ip_connect_supervised`.
506        if !ctx.policy.ip_connect_supervised(ip.is_loopback()) {
507            return NotifAction::Continue;
508        }
509        let dest_port = parse_port_from_sockaddr(&addr_bytes);
510        let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd) {
511            Ok(fd) => fd,
512            Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
513        };
514        let protocol = match query_socket_protocol(dup_fd.as_raw_fd()) {
515            Some(p) => p,
516            None => return NotifAction::Errno(ECONNREFUSED),
517        };
518        let ns = ctx.network.lock().await;
519        let live_policy = {
520            let pfs = ctx.policy_fn.lock().await;
521            pfs.live_policy.clone()
522        };
523        let effective = ns.effective_network_policy(notif.pid, protocol, live_policy.as_ref());
524        match (effective, dest_port) {
525            (crate::seccomp::notif::NetworkPolicy::Unrestricted, _) => {
526                // No rules for this protocol's wildcard — Landlock (TCP
527                // only) or the protocol's wildcard rule covers it; no
528                // additional check here.
529            }
530            (policy, Some(p)) => {
531                // For ICMP rules every per-IP entry is `PortAllow::Any`,
532                // so the port arg from the sockaddr (typically 0 or the
533                // ICMP id) is functionally ignored — IP is what matters.
534                if !policy.allows(ip, p) {
535                    return NotifAction::Errno(ECONNREFUSED);
536                }
537            }
538            (_, None) => {
539                // Couldn't parse port from sockaddr — fail closed.
540                return NotifAction::Errno(ECONNREFUSED);
541            }
542        }
543        // Check for HTTP ACL redirect
544        let http_acl_addr = ns.http_acl_addr;
545        let http_acl_intercept = dest_port.map_or(false, |p| ns.http_acl_ports.contains(&p));
546        let http_acl_orig_dest = ns.http_acl_orig_dest.clone();
547        let remapped_loopback_port = if ctx.policy.port_remap && ip.is_loopback() {
548            dest_port.and_then(|p| ns.port_map.get_real(p))
549        } else {
550            None
551        };
552
553        drop(ns);
554
555        // Determine the actual connect target (redirect HTTP/HTTPS to proxy)
556        let mut redirected = false;
557        let is_ipv6 = parse_ip_from_sockaddr(&addr_bytes)
558            .map_or(false, |ip| ip.is_ipv6());
559        let (mut connect_addr, connect_len) = if let Some(proxy_addr) = http_acl_addr {
560            if http_acl_intercept {
561                redirected = true;
562                if is_ipv6 {
563                    // IPv6 socket: redirect via IPv4-mapped IPv6 address
564                    // (::ffff:127.0.0.1) so it connects to the IPv4 proxy.
565                    let mut sa6: libc::sockaddr_in6 = unsafe { std::mem::zeroed() };
566                    sa6.sin6_family = libc::AF_INET6 as u16;
567                    sa6.sin6_port = proxy_addr.port().to_be();
568                    // Build ::ffff:127.0.0.1
569                    let mapped = std::net::Ipv6Addr::from(
570                        match proxy_addr {
571                            std::net::SocketAddr::V4(v4) => v4.ip().to_ipv6_mapped(),
572                            std::net::SocketAddr::V6(v6) => *v6.ip(),
573                        }
574                    );
575                    sa6.sin6_addr.s6_addr = mapped.octets();
576                    let bytes = unsafe {
577                        std::slice::from_raw_parts(
578                            &sa6 as *const _ as *const u8,
579                            std::mem::size_of::<libc::sockaddr_in6>(),
580                        )
581                    }
582                    .to_vec();
583                    (bytes, std::mem::size_of::<libc::sockaddr_in6>() as u32)
584                } else {
585                    // IPv4 socket: redirect directly.
586                    let mut sa: libc::sockaddr_in = unsafe { std::mem::zeroed() };
587                    sa.sin_family = libc::AF_INET as u16;
588                    sa.sin_port = proxy_addr.port().to_be();
589                    match proxy_addr {
590                        std::net::SocketAddr::V4(v4) => {
591                            sa.sin_addr.s_addr = u32::from_ne_bytes(v4.ip().octets());
592                        }
593                        std::net::SocketAddr::V6(_) => {
594                            // Proxy always binds to 127.0.0.1
595                            return NotifAction::Errno(libc::EAFNOSUPPORT);
596                        }
597                    }
598                    let bytes = unsafe {
599                        std::slice::from_raw_parts(
600                            &sa as *const _ as *const u8,
601                            std::mem::size_of::<libc::sockaddr_in>(),
602                        )
603                    }
604                    .to_vec();
605                    (bytes, std::mem::size_of::<libc::sockaddr_in>() as u32)
606                }
607            } else {
608                (addr_bytes.clone(), addr_len)
609            }
610        } else {
611            (addr_bytes.clone(), addr_len)
612        };
613        if !redirected {
614            if let Some(real_port) = remapped_loopback_port {
615                // The child sees virtual ports via getsockname(); connect
616                // still has to target the real bound loopback port.
617                set_port_in_sockaddr(&mut connect_addr, real_port);
618            }
619        }
620
621        // (The supervisor-side dup is the same fd we already created
622        // for the SO_PROTOCOL probe above — reuse it rather than
623        // pidfd_getfd-ing a second time.)
624
625        // 4. Record original dest IP *before* connect to prevent TOCTOU race:
626        //    the proxy may receive the request before we write the mapping if
627        //    we do it after connect(). We already have the original IP from
628        //    addr_bytes (our immune copy).
629        if redirected {
630            if let Some(ref orig_dest_map) = http_acl_orig_dest {
631                if let Some(orig_ip) = parse_ip_from_sockaddr(&addr_bytes) {
632                    // Bind the socket so getsockname() returns the local addr
633                    // the proxy will see as client_addr.
634                    if is_ipv6 {
635                        let mut bind_sa6: libc::sockaddr_in6 = unsafe { std::mem::zeroed() };
636                        bind_sa6.sin6_family = libc::AF_INET6 as u16;
637                        // port 0 + IN6ADDR_ANY = kernel picks ephemeral port
638                        unsafe {
639                            libc::bind(
640                                dup_fd.as_raw_fd(),
641                                &bind_sa6 as *const _ as *const libc::sockaddr,
642                                std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t,
643                            );
644                        }
645                        let mut local_sa6: libc::sockaddr_in6 = unsafe { std::mem::zeroed() };
646                        let mut local_len: libc::socklen_t =
647                            std::mem::size_of::<libc::sockaddr_in6>() as libc::socklen_t;
648                        let gs_ret = unsafe {
649                            libc::getsockname(
650                                dup_fd.as_raw_fd(),
651                                &mut local_sa6 as *mut _ as *mut libc::sockaddr,
652                                &mut local_len,
653                            )
654                        };
655                        if gs_ret == 0 {
656                            let local_port = u16::from_be(local_sa6.sin6_port);
657                            let local_ip = Ipv6Addr::from(local_sa6.sin6_addr.s6_addr);
658                            let local_addr = std::net::SocketAddr::V6(
659                                std::net::SocketAddrV6::new(local_ip, local_port, 0, 0),
660                            );
661                            if let Ok(mut map) = orig_dest_map.write() {
662                                map.insert(local_addr, orig_ip);
663                            }
664                        }
665                    } else {
666                        let mut bind_sa: libc::sockaddr_in = unsafe { std::mem::zeroed() };
667                        bind_sa.sin_family = libc::AF_INET as u16;
668                        // port 0 + INADDR_ANY = kernel picks ephemeral port
669                        unsafe {
670                            libc::bind(
671                                dup_fd.as_raw_fd(),
672                                &bind_sa as *const _ as *const libc::sockaddr,
673                                std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
674                            );
675                        }
676                        let mut local_sa: libc::sockaddr_in = unsafe { std::mem::zeroed() };
677                        let mut local_len: libc::socklen_t =
678                            std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t;
679                        let gs_ret = unsafe {
680                            libc::getsockname(
681                                dup_fd.as_raw_fd(),
682                                &mut local_sa as *mut _ as *mut libc::sockaddr,
683                                &mut local_len,
684                            )
685                        };
686                        if gs_ret == 0 {
687                            let local_port = u16::from_be(local_sa.sin_port);
688                            let local_ip = Ipv4Addr::from(u32::from_be(local_sa.sin_addr.s_addr));
689                            let local_addr = std::net::SocketAddr::V4(
690                                std::net::SocketAddrV4::new(local_ip, local_port),
691                            );
692                            if let Ok(mut map) = orig_dest_map.write() {
693                                map.insert(local_addr, orig_ip);
694                            }
695                        }
696                    }
697                }
698            }
699        }
700
701        // 5. Perform connect in supervisor with our validated sockaddr
702        let ret = unsafe {
703            libc::connect(
704                dup_fd.as_raw_fd(),
705                connect_addr.as_ptr() as *const libc::sockaddr,
706                connect_len as libc::socklen_t,
707            )
708        };
709
710        // 6. Return result.
711        // On failure, the stale orig_dest entry is harmless: the proxy never
712        // sees this connection, and the entry will be cleaned up on the next
713        // successful request from the same local address (or on shutdown).
714        if ret == 0 {
715            NotifAction::ReturnValue(0)
716        } else {
717            let errno = unsafe { *libc::__errno_location() };
718            NotifAction::Errno(errno)
719        }
720        // dup_fd dropped here, closing supervisor's copy
721    } else {
722        // Non-IP family. A NAMED (pathname) AF_UNIX connect is a gap Landlock
723        // cannot close (it has no access right for unix-socket connect), so a
724        // netns-less sandbox could reach a host service socket and escape.
725        // Connecting is a WRITE on the socket inode (kernel: unix_find_other ->
726        // path_permission(MAY_WRITE)), so require the path to be covered by an
727        // fs-write grant, mirroring the kernel's own DAC; otherwise deny with
728        // EACCES. The decision is made on `addr_bytes` (our immune copy) and we
729        // never return Continue on the deny path, so it is TOCTOU-safe.
730        // Abstract sockets (no path) are handled by the Landlock abstract scope.
731        match named_unix_socket_path(&addr_bytes) {
732            Some(path) if ctx.policy.has_unix_fs_gate => {
733                if ctx.policy.chroot_root.is_some() {
734                    // Chroot mode: the child's paths are virtual, so a lexical
735                    // check against the (virtual) write grants is consistent,
736                    // and host socket paths are absent from the chroot view
737                    // anyway. Deny unless under a write grant.
738                    if path_under_any(&path, &ctx.policy.chroot_writable) {
739                        NotifAction::Continue
740                    } else {
741                        NotifAction::Errno(libc::EACCES)
742                    }
743                } else {
744                    // Non-chroot: resolve the symlink-followed real target and
745                    // connect on-behalf to the pinned inode, so a symlink inside
746                    // a granted dir cannot redirect to an ungranted socket.
747                    connect_named_unix_on_behalf(
748                        notif.pid,
749                        sockfd,
750                        &path,
751                        &ctx.policy.chroot_writable,
752                    )
753                }
754            }
755            // Abstract/unnamed socket, non-AF_UNIX family, or gate disabled.
756            _ => NotifAction::Continue,
757        }
758    }
759}
760
761/// Resolve a named unix socket `sun_path` to its real, symlink-followed inode
762/// in the child's root view (`/proc/<pid>/root`) and verify that inode is under
763/// an fs-write grant. On success returns a pinned `O_PATH` fd to that exact
764/// inode; on failure returns the deny/refuse `NotifAction`. Callers must
765/// operate on the pinned fd via `/proc/self/fd` so the checked inode is the one
766/// acted on, immune to a path swap after the check (TOCTOU- and symlink-safe).
767fn resolve_named_unix_target(
768    child_pid: u32,
769    sun_path: &std::path::Path,
770    writable: &[std::path::PathBuf],
771) -> Result<OwnedFd, NotifAction> {
772    // Resolve in the child's mount/root view so its symlinks (not ours) decide
773    // the target. `O_PATH` follows symlinks to the real socket inode and pins
774    // it without performing any I/O on the socket.
775    let proc_path = format!("/proc/{}/root{}", child_pid, sun_path.display());
776    let c_proc = std::ffi::CString::new(proc_path)
777        .map_err(|_| NotifAction::Errno(libc::EACCES))?;
778    let pinned_raw = unsafe { libc::open(c_proc.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
779    if pinned_raw < 0 {
780        // Target missing or unreachable: refuse without leaking the reason.
781        return Err(NotifAction::Errno(ECONNREFUSED));
782    }
783    let pinned = unsafe { OwnedFd::from_raw_fd(pinned_raw) };
784
785    // Canonical path of the pinned inode in our mount namespace.
786    let real = std::fs::read_link(format!("/proc/self/fd/{}", pinned.as_raw_fd()))
787        .map_err(|_| NotifAction::Errno(libc::EACCES))?;
788    if real_path_under_any(&real, writable) {
789        Ok(pinned)
790    } else {
791        Err(NotifAction::Errno(libc::EACCES))
792    }
793}
794
795/// Build a `sockaddr_un` addressing `/proc/self/fd/<fd>`. The kernel resolves
796/// it to the exact pinned inode, so connecting/sending to it targets the inode
797/// we validated rather than re-resolving a path string. Returns `None` only if
798/// the rendered path would overflow `sun_path` (never, in practice).
799fn proc_self_fd_sockaddr(fd: RawFd) -> Option<(libc::sockaddr_un, libc::socklen_t)> {
800    let path = format!("/proc/self/fd/{}", fd);
801    let bytes = path.as_bytes();
802    let mut sun: libc::sockaddr_un = unsafe { std::mem::zeroed() };
803    if bytes.len() >= sun.sun_path.len() {
804        return None;
805    }
806    sun.sun_family = libc::AF_UNIX as libc::sa_family_t;
807    for (i, &b) in bytes.iter().enumerate() {
808        sun.sun_path[i] = b as libc::c_char;
809    }
810    let len = (std::mem::size_of::<libc::sa_family_t>() + bytes.len() + 1) as libc::socklen_t;
811    Some((sun, len))
812}
813
814/// On-behalf `connect()` for a NAMED `AF_UNIX` socket in non-chroot mode:
815/// resolve+verify the target, then connect the child's socket to the pinned
816/// inode through `/proc/self/fd`.
817fn connect_named_unix_on_behalf(
818    child_pid: u32,
819    sockfd: i32,
820    sun_path: &std::path::Path,
821    writable: &[std::path::PathBuf],
822) -> NotifAction {
823    let pinned = match resolve_named_unix_target(child_pid, sun_path, writable) {
824        Ok(fd) => fd,
825        Err(action) => return action,
826    };
827    let (sun, len) = match proc_self_fd_sockaddr(pinned.as_raw_fd()) {
828        Some(s) => s,
829        None => return NotifAction::Errno(libc::ENAMETOOLONG),
830    };
831    let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(child_pid, sockfd) {
832        Ok(fd) => fd,
833        Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
834    };
835    let ret = unsafe {
836        libc::connect(
837            dup_fd.as_raw_fd(),
838            &sun as *const libc::sockaddr_un as *const libc::sockaddr,
839            len,
840        )
841    };
842    if ret == 0 {
843        NotifAction::ReturnValue(0)
844    } else {
845        NotifAction::Errno(unsafe { *libc::__errno_location() })
846    }
847}
848
849/// On-behalf `sendto()` for a NAMED `AF_UNIX` datagram in non-chroot mode:
850/// resolve+verify the target, copy the child's data, then send to the pinned
851/// inode through `/proc/self/fd`.
852fn sendto_named_unix_on_behalf(
853    notif: &SeccompNotif,
854    notif_fd: RawFd,
855    sockfd: i32,
856    buf_ptr: u64,
857    buf_len: usize,
858    flags: i32,
859    sun_path: &std::path::Path,
860    writable: &[std::path::PathBuf],
861) -> NotifAction {
862    let pinned = match resolve_named_unix_target(notif.pid, sun_path, writable) {
863        Ok(fd) => fd,
864        Err(action) => return action,
865    };
866    let (sun, len) = match proc_self_fd_sockaddr(pinned.as_raw_fd()) {
867        Some(s) => s,
868        None => return NotifAction::Errno(libc::ENAMETOOLONG),
869    };
870    let data = match read_child_mem(notif_fd, notif.id, notif.pid, buf_ptr, buf_len) {
871        Ok(b) => b,
872        Err(_) => return NotifAction::Errno(libc::EIO),
873    };
874    let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd) {
875        Ok(fd) => fd,
876        Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
877    };
878    let ret = unsafe {
879        libc::sendto(
880            dup_fd.as_raw_fd(),
881            data.as_ptr() as *const libc::c_void,
882            data.len(),
883            flags,
884            &sun as *const libc::sockaddr_un as *const libc::sockaddr,
885            len,
886        )
887    };
888    if ret >= 0 {
889        NotifAction::ReturnValue(ret as i64)
890    } else {
891        NotifAction::Errno(unsafe { *libc::__errno_location() })
892    }
893}
894
895/// True if `real` (an already-canonical path) is at or under any of `prefixes`,
896/// canonicalizing each prefix so a symlinked grant path still matches.
897fn real_path_under_any(real: &std::path::Path, prefixes: &[std::path::PathBuf]) -> bool {
898    prefixes.iter().any(|p| {
899        let canon = std::fs::canonicalize(p);
900        real.starts_with(canon.as_deref().unwrap_or(p))
901    })
902}
903
904/// Apply the named-unix fs gate to a `sendmsg()` whose `msg_name` may address a
905/// unix socket. Returns `Some(action)` when the target is a named `AF_UNIX`
906/// socket (handled here), or `None` to fall through to the IP path (connected
907/// socket, IP family, abstract socket, or an unreadable header).
908fn unix_sendmsg_gate(
909    notif: &SeccompNotif,
910    ctx: &Arc<SupervisorCtx>,
911    notif_fd: RawFd,
912    sockfd: i32,
913    msghdr_ptr: u64,
914    flags: i32,
915) -> Option<NotifAction> {
916    let msghdr_bytes = read_child_mem(notif_fd, notif.id, notif.pid, msghdr_ptr, 56).ok()?;
917    if msghdr_bytes.len() < 56 {
918        return None;
919    }
920    let msg_name_ptr = u64::from_ne_bytes(msghdr_bytes[0..8].try_into().unwrap());
921    if msg_name_ptr == 0 {
922        return None; // connected socket: no address to gate
923    }
924    let msg_namelen = u32::from_ne_bytes(msghdr_bytes[8..12].try_into().unwrap());
925    let addr_bytes =
926        read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, msg_namelen as usize).ok()?;
927    // None unless this is a NAMED AF_UNIX target; IP/abstract fall through.
928    let path = named_unix_socket_path(&addr_bytes)?;
929
930    if ctx.policy.chroot_root.is_some() {
931        return Some(if path_under_any(&path, &ctx.policy.chroot_writable) {
932            NotifAction::Continue
933        } else {
934            NotifAction::Errno(libc::EACCES)
935        });
936    }
937    Some(sendmsg_named_unix_on_behalf(
938        notif,
939        notif_fd,
940        sockfd,
941        msghdr_ptr,
942        flags,
943        &path,
944        &ctx.policy.chroot_writable,
945    ))
946}
947
948/// On-behalf `sendmsg()` for a NAMED `AF_UNIX` datagram in non-chroot mode:
949/// resolve+verify the target, copy the message's iovecs and control data, then
950/// send to the pinned inode through `/proc/self/fd`.
951fn sendmsg_named_unix_on_behalf(
952    notif: &SeccompNotif,
953    notif_fd: RawFd,
954    sockfd: i32,
955    msghdr_ptr: u64,
956    flags: i32,
957    sun_path: &std::path::Path,
958    writable: &[std::path::PathBuf],
959) -> NotifAction {
960    match send_named_unix_msghdr(notif, notif_fd, sockfd, msghdr_ptr, flags, sun_path, writable) {
961        Ok(n) => NotifAction::ReturnValue(n as i64),
962        Err(errno) => NotifAction::Errno(errno),
963    }
964}
965
966/// Core of the named-unix on-behalf `sendmsg`: resolve+verify `sun_path`, copy
967/// the message's iovecs/control from the child, and send to the pinned inode
968/// via `/proc/self/fd`. Returns the bytes sent or an errno. Shared by the
969/// single-message `sendmsg` path and the per-entry `sendmmsg` path.
970fn send_named_unix_msghdr(
971    notif: &SeccompNotif,
972    notif_fd: RawFd,
973    sockfd: i32,
974    msghdr_ptr: u64,
975    flags: i32,
976    sun_path: &std::path::Path,
977    writable: &[std::path::PathBuf],
978) -> Result<isize, i32> {
979    let pinned = match resolve_named_unix_target(notif.pid, sun_path, writable) {
980        Ok(fd) => fd,
981        Err(NotifAction::Errno(e)) => return Err(e),
982        Err(_) => return Err(libc::EACCES),
983    };
984    let (sun, sun_len) = proc_self_fd_sockaddr(pinned.as_raw_fd()).ok_or(libc::ENAMETOOLONG)?;
985
986    let msghdr_bytes = match read_child_mem(notif_fd, notif.id, notif.pid, msghdr_ptr, 56) {
987        Ok(b) if b.len() >= 56 => b,
988        _ => return Err(libc::EFAULT),
989    };
990    let msg_iov_ptr = u64::from_ne_bytes(msghdr_bytes[16..24].try_into().unwrap());
991    let msg_iovlen = u64::from_ne_bytes(msghdr_bytes[24..32].try_into().unwrap());
992    let msg_control_ptr = u64::from_ne_bytes(msghdr_bytes[32..40].try_into().unwrap());
993    let msg_controllen = u64::from_ne_bytes(msghdr_bytes[40..48].try_into().unwrap());
994
995    let iovlen = (msg_iovlen as usize).min(1024);
996    let iov_bytes = read_child_mem(notif_fd, notif.id, notif.pid, msg_iov_ptr, iovlen * 16)
997        .map_err(|_| libc::EIO)?;
998    let mut data_bufs: Vec<Vec<u8>> = Vec::with_capacity(iovlen);
999    for i in 0..iovlen {
1000        let off = i * 16;
1001        if off + 16 > iov_bytes.len() {
1002            break;
1003        }
1004        let base = u64::from_ne_bytes(iov_bytes[off..off + 8].try_into().unwrap());
1005        let len = u64::from_ne_bytes(iov_bytes[off + 8..off + 16].try_into().unwrap()) as usize;
1006        if len > MAX_SEND_BUF {
1007            return Err(libc::EMSGSIZE);
1008        }
1009        if base == 0 || len == 0 {
1010            data_bufs.push(Vec::new());
1011            continue;
1012        }
1013        data_bufs.push(read_child_mem(notif_fd, notif.id, notif.pid, base, len).map_err(|_| libc::EIO)?);
1014    }
1015    let mut local_iovs: Vec<libc::iovec> = data_bufs
1016        .iter()
1017        .map(|b| libc::iovec {
1018            iov_base: b.as_ptr() as *mut libc::c_void,
1019            iov_len: b.len(),
1020        })
1021        .collect();
1022
1023    let control_buf = if msg_control_ptr != 0 && msg_controllen > 0 {
1024        let len = (msg_controllen as usize).min(4096);
1025        read_child_mem(notif_fd, notif.id, notif.pid, msg_control_ptr, len).ok()
1026    } else {
1027        None
1028    };
1029
1030    let dup_fd = crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd)
1031        .map_err(|e| e.raw_os_error().unwrap_or(libc::EBADF))?;
1032
1033    let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
1034    msg.msg_name = &sun as *const libc::sockaddr_un as *mut libc::c_void;
1035    msg.msg_namelen = sun_len;
1036    msg.msg_iov = local_iovs.as_mut_ptr();
1037    msg.msg_iovlen = local_iovs.len();
1038    if let Some(ref ctrl) = control_buf {
1039        msg.msg_control = ctrl.as_ptr() as *mut libc::c_void;
1040        msg.msg_controllen = ctrl.len();
1041    }
1042    let ret = unsafe { libc::sendmsg(dup_fd.as_raw_fd(), &msg, flags) };
1043    if ret >= 0 {
1044        Ok(ret)
1045    } else {
1046        Err(unsafe { *libc::__errno_location() })
1047    }
1048}
1049
1050/// Read a `sendmmsg` entry's `msg_name` and return its NAMED `AF_UNIX` path, or
1051/// `None` for a connected (null-name), IP, or abstract entry. The entry's
1052/// `msghdr` is the first field of `struct mmsghdr`, so it begins at `entry_ptr`.
1053fn mmsg_entry_named_unix_path(
1054    notif: &SeccompNotif,
1055    notif_fd: RawFd,
1056    entry_ptr: u64,
1057) -> Option<std::path::PathBuf> {
1058    let hdr = read_child_mem(notif_fd, notif.id, notif.pid, entry_ptr, 12).ok()?;
1059    if hdr.len() < 12 {
1060        return None;
1061    }
1062    let msg_name_ptr = u64::from_ne_bytes(hdr[0..8].try_into().unwrap());
1063    if msg_name_ptr == 0 {
1064        return None;
1065    }
1066    let msg_namelen = u32::from_ne_bytes(hdr[8..12].try_into().unwrap());
1067    let addr_bytes =
1068        read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, msg_namelen as usize).ok()?;
1069    named_unix_socket_path(&addr_bytes)
1070}
1071
1072/// On-behalf `sendmmsg` for a batch containing NAMED `AF_UNIX` entries
1073/// (non-chroot). Each named-unix entry is resolved, verified, and sent to its
1074/// pinned inode; the loop stops at the first entry it cannot gate on-behalf
1075/// (connected/abstract) or that is denied, returning the count sent so far
1076/// (standard short-`sendmmsg` semantics). Never returns `Continue`, so a unix
1077/// entry cannot ride out via the binary whole-call passthrough.
1078fn sendmmsg_named_unix_on_behalf(
1079    notif: &SeccompNotif,
1080    notif_fd: RawFd,
1081    sockfd: i32,
1082    msgvec_ptr: u64,
1083    vlen: usize,
1084    flags: i32,
1085    writable: &[std::path::PathBuf],
1086) -> NotifAction {
1087    let mut sent: usize = 0;
1088    let mut first_errno: Option<i32> = None;
1089    for i in 0..vlen {
1090        let entry_ptr = msgvec_ptr + (i * MMSGHDR_SIZE) as u64;
1091        let path = match mmsg_entry_named_unix_path(notif, notif_fd, entry_ptr) {
1092            Some(p) => p,
1093            // Connected/abstract/unreadable entry: cannot gate on-behalf, so
1094            // stop here and report a short send rather than passing it through.
1095            None => break,
1096        };
1097        match send_named_unix_msghdr(notif, notif_fd, sockfd, entry_ptr, flags, &path, writable) {
1098            Ok(n) => {
1099                let bytes = (n as u32).to_ne_bytes();
1100                let _ = write_child_mem(
1101                    notif_fd,
1102                    notif.id,
1103                    notif.pid,
1104                    entry_ptr + MSG_LEN_OFFSET as u64,
1105                    &bytes,
1106                );
1107                sent += 1;
1108            }
1109            Err(errno) => {
1110                first_errno = Some(errno);
1111                break;
1112            }
1113        }
1114    }
1115    if sent > 0 {
1116        NotifAction::ReturnValue(sent as i64)
1117    } else {
1118        NotifAction::Errno(first_errno.unwrap_or(libc::EACCES))
1119    }
1120}
1121
1122/// Extract the filesystem path of a NAMED `AF_UNIX` connect target from a raw
1123/// `sockaddr`. Returns `None` for abstract sockets (`sun_path[0] == 0`),
1124/// unnamed sockets, or any non-`AF_UNIX` family (none of which the fs gate
1125/// applies to).
1126fn named_unix_socket_path(addr_bytes: &[u8]) -> Option<std::path::PathBuf> {
1127    // sockaddr_un layout: u16 sun_family, then sun_path. Need the family plus
1128    // at least one path byte.
1129    if addr_bytes.len() < 3 {
1130        return None;
1131    }
1132    let family = u16::from_ne_bytes([addr_bytes[0], addr_bytes[1]]);
1133    if family != libc::AF_UNIX as u16 {
1134        return None;
1135    }
1136    let sun_path = &addr_bytes[2..];
1137    if sun_path[0] == 0 {
1138        return None; // abstract namespace (Landlock scope handles it)
1139    }
1140    let end = sun_path.iter().position(|&b| b == 0).unwrap_or(sun_path.len());
1141    let raw = &sun_path[..end];
1142    if raw.is_empty() {
1143        return None;
1144    }
1145    std::str::from_utf8(raw).ok().map(std::path::PathBuf::from)
1146}
1147
1148/// True if `path`, lexically normalized (`.`/`..` resolved without touching the
1149/// filesystem), is at or under any of the granted `prefixes`. Mirrors the
1150/// prefix matching the chroot fs enforcement uses.
1151fn path_under_any(path: &std::path::Path, prefixes: &[std::path::PathBuf]) -> bool {
1152    let norm = crate::chroot::resolve::confine(&path.to_string_lossy());
1153    prefixes.iter().any(|p| norm.starts_with(p))
1154}
1155
1156// ============================================================
1157// sendto_on_behalf / sendmsg_on_behalf — on-behalf (TOCTOU-safe)
1158// ============================================================
1159
1160/// Perform sendto() on behalf of the child process (TOCTOU-safe).
1161///
1162/// 1. Copy sockaddr from child memory (our copy — immune to TOCTOU)
1163/// 2. Check IP against allowlist on our copy
1164/// 3. Copy data buffer from child memory
1165/// 4. Duplicate child's socket fd via pidfd_getfd
1166/// 5. sendto() in supervisor with validated sockaddr + copied data
1167/// 6. Return byte count or errno
1168///
1169/// Only triggers for unconnected sends (addr_ptr != NULL), which is
1170/// primarily UDP. Connected sockets (addr_ptr == NULL) use CONTINUE.
1171async fn sendto_on_behalf(
1172    notif: &SeccompNotif,
1173    ctx: &Arc<SupervisorCtx>,
1174    notif_fd: RawFd,
1175) -> NotifAction {
1176    let args = &notif.data.args;
1177    let sockfd = args[0] as i32;
1178    let buf_ptr = args[1];
1179    let buf_len = args[2] as usize;
1180    if buf_len > MAX_SEND_BUF {
1181        return NotifAction::Errno(libc::EMSGSIZE);
1182    }
1183    let flags = args[3] as i32;
1184    let addr_ptr = args[4];
1185    let addr_len = args[5] as u32;
1186
1187    if addr_ptr == 0 {
1188        return NotifAction::Continue; // connected socket, no addr to check
1189    }
1190
1191    // 1. Copy sockaddr from child memory (small: 16-28 bytes)
1192    let addr_bytes =
1193        match read_child_mem(notif_fd, notif.id, notif.pid, addr_ptr, addr_len as usize) {
1194            Ok(b) => b,
1195            Err(_) => return NotifAction::Errno(libc::EIO),
1196        };
1197
1198    // 2. Check (ip, port) against the per-protocol endpoint allowlist.
1199    // One pidfd_getfd serves both the SO_PROTOCOL probe and the
1200    // on-behalf sendto.
1201    if let Some(ip) = parse_ip_from_sockaddr(&addr_bytes) {
1202        let dest_port = parse_port_from_sockaddr(&addr_bytes);
1203        let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd) {
1204            Ok(fd) => fd,
1205            Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
1206        };
1207        let protocol = match query_socket_protocol(dup_fd.as_raw_fd()) {
1208            Some(p) => p,
1209            None => return NotifAction::Errno(ECONNREFUSED),
1210        };
1211        let ns = ctx.network.lock().await;
1212        let live_policy = {
1213            let pfs = ctx.policy_fn.lock().await;
1214            pfs.live_policy.clone()
1215        };
1216        let effective = ns.effective_network_policy(notif.pid, protocol, live_policy.as_ref());
1217        if !matches!(effective, crate::seccomp::notif::NetworkPolicy::Unrestricted) {
1218            match dest_port {
1219                Some(p) if !effective.allows(ip, p) => {
1220                    return NotifAction::Errno(ECONNREFUSED);
1221                }
1222                None => return NotifAction::Errno(ECONNREFUSED),
1223                Some(_) => {}
1224            }
1225        }
1226        drop(ns);
1227
1228        // 3. Copy data buffer from child memory
1229        let data = match read_child_mem(notif_fd, notif.id, notif.pid, buf_ptr, buf_len) {
1230            Ok(b) => b,
1231            Err(_) => return NotifAction::Errno(libc::EIO),
1232        };
1233
1234        // 4. (dup_fd from step 2 is reused for the supervisor sendto.)
1235
1236        // 5. Perform sendto in supervisor with validated sockaddr + copied data
1237        let ret = unsafe {
1238            libc::sendto(
1239                dup_fd.as_raw_fd(),
1240                data.as_ptr() as *const libc::c_void,
1241                data.len(),
1242                flags,
1243                addr_bytes.as_ptr() as *const libc::sockaddr,
1244                addr_len as libc::socklen_t,
1245            )
1246        };
1247
1248        // 6. Return result
1249        if ret >= 0 {
1250            NotifAction::ReturnValue(ret as i64)
1251        } else {
1252            let errno = unsafe { *libc::__errno_location() };
1253            NotifAction::Errno(errno)
1254        }
1255    } else {
1256        // Non-IP family. Gate a NAMED AF_UNIX datagram the same way as connect:
1257        // sendto to a named socket is a WRITE on its inode, so deny unless the
1258        // resolved real target is under an fs-write grant.
1259        match named_unix_socket_path(&addr_bytes) {
1260            Some(path) if ctx.policy.has_unix_fs_gate => {
1261                if ctx.policy.chroot_root.is_some() {
1262                    if path_under_any(&path, &ctx.policy.chroot_writable) {
1263                        NotifAction::Continue
1264                    } else {
1265                        NotifAction::Errno(libc::EACCES)
1266                    }
1267                } else {
1268                    sendto_named_unix_on_behalf(
1269                        notif,
1270                        notif_fd,
1271                        sockfd,
1272                        buf_ptr,
1273                        buf_len,
1274                        flags,
1275                        &path,
1276                        &ctx.policy.chroot_writable,
1277                    )
1278                }
1279            }
1280            _ => NotifAction::Continue,
1281        }
1282    }
1283}
1284
1285/// Perform sendmsg() on behalf of the child process (TOCTOU-safe).
1286///
1287/// 1. Copy full msghdr from child memory
1288/// 2. Copy sockaddr from msg_name (our copy — immune to TOCTOU)
1289/// 3. Check IP against allowlist on our copy
1290/// 4. Copy iovec data buffers from child memory
1291/// 5. Copy control message buffer from child memory
1292/// 6. Duplicate child's socket fd via pidfd_getfd
1293/// 7. sendmsg() in supervisor with validated sockaddr + copied data
1294/// 8. Return byte count or errno
1295async fn sendmsg_on_behalf(
1296    notif: &SeccompNotif,
1297    ctx: &Arc<SupervisorCtx>,
1298    notif_fd: RawFd,
1299) -> NotifAction {
1300    let args = &notif.data.args;
1301    let sockfd = args[0] as i32;
1302    let msghdr_ptr = args[1];
1303    let flags = args[2] as i32;
1304
1305    // Named-unix datagram gate. A named AF_UNIX `msg_name` is handled here; the
1306    // IP path below only covers AF_INET/AF_INET6, and would pass a unix target
1307    // straight through.
1308    if ctx.policy.has_unix_fs_gate {
1309        if let Some(action) = unix_sendmsg_gate(notif, ctx, notif_fd, sockfd, msghdr_ptr, flags) {
1310            return action;
1311        }
1312    }
1313
1314    // With a destination policy active, never Continue: the kernel would
1315    // re-read `msg_name` from child memory, where a racing thread could swap a
1316    // connected (NULL) name for a denied address. Send on-behalf (including
1317    // connected sends) so the verdict is made on the immune copy. Without a
1318    // policy there is nothing to bypass, so the Continue fast path below stands.
1319    let dest_policy = ctx.policy.has_net_destination_policy;
1320    if !dest_policy {
1321        // Pre-scan for Continue cases (connected socket / non-IP family).
1322        // EFAULT on unreadable msghdr (vs. Continue, which would let the kernel
1323        // re-read child memory and bypass our check).
1324        match prescan_msghdr(notif, notif_fd, msghdr_ptr) {
1325            PrescanResult::ContinueWholeCall => return NotifAction::Continue,
1326            PrescanResult::Errno(e) => return NotifAction::Errno(e),
1327            PrescanResult::OnBehalf => {}
1328        }
1329    }
1330
1331    let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd) {
1332        Ok(fd) => fd,
1333        Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
1334    };
1335    let protocol = match query_socket_protocol(dup_fd.as_raw_fd()) {
1336        Some(p) => p,
1337        None => return NotifAction::Errno(ECONNREFUSED),
1338    };
1339
1340    match send_msghdr_on_behalf(notif, ctx, notif_fd, &dup_fd, protocol, msghdr_ptr, flags).await {
1341        Ok(n) => NotifAction::ReturnValue(n as i64),
1342        Err(errno) => NotifAction::Errno(errno),
1343    }
1344}
1345
1346// ============================================================
1347// prescan_msghdr / send_msghdr_on_behalf — shared per-message work
1348// ============================================================
1349
1350#[derive(Clone, Copy)]
1351enum PrescanResult {
1352    /// All fields present, IP-family destination — caller can take the
1353    /// on-behalf path with `send_msghdr_on_behalf`.
1354    OnBehalf,
1355    /// `msg_name == NULL` (connected socket) or non-IP family
1356    /// (AF_UNIX etc.). Caller should return `NotifAction::Continue` so
1357    /// the kernel handles the syscall in the child's namespace —
1358    /// AF_UNIX path resolution is the canonical reason we don't take
1359    /// these messages on behalf.
1360    ContinueWholeCall,
1361    /// Memory read failure. Caller maps to the appropriate errno
1362    /// (EFAULT for unreadable msghdr, EIO for the sockaddr).
1363    Errno(i32),
1364}
1365
1366/// Probe one `struct msghdr` to decide whether the on-behalf path
1367/// applies. Used by both `sendmsg_on_behalf` (one msghdr) and
1368/// `sendmmsg_on_behalf` (one per `mmsghdr` entry, before doing any
1369/// sends — Continue is a whole-syscall decision).
1370fn prescan_msghdr(
1371    notif: &SeccompNotif,
1372    notif_fd: RawFd,
1373    msghdr_ptr: u64,
1374) -> PrescanResult {
1375    let msghdr_bytes = match read_child_mem(notif_fd, notif.id, notif.pid, msghdr_ptr, 56) {
1376        Ok(b) if b.len() >= 56 => b,
1377        _ => return PrescanResult::Errno(libc::EFAULT),
1378    };
1379    let msg_name_ptr = u64::from_ne_bytes(msghdr_bytes[0..8].try_into().unwrap());
1380    if msg_name_ptr == 0 {
1381        return PrescanResult::ContinueWholeCall;
1382    }
1383    let msg_namelen = u32::from_ne_bytes(msghdr_bytes[8..12].try_into().unwrap());
1384    let addr_bytes = match read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, msg_namelen as usize) {
1385        Ok(b) => b,
1386        Err(_) => return PrescanResult::Errno(libc::EIO),
1387    };
1388    if parse_ip_from_sockaddr(&addr_bytes).is_none() {
1389        return PrescanResult::ContinueWholeCall;
1390    }
1391    PrescanResult::OnBehalf
1392}
1393
1394/// Validate, materialize, and send one `struct msghdr` on behalf of
1395/// the child. Caller is responsible for:
1396///   - dup'ing the child fd (`dup_fd`),
1397///   - resolving the socket protocol (`protocol`) via
1398///     `query_socket_protocol` on that dup,
1399///   - having confirmed via `prescan_msghdr` that `msghdr_ptr` points
1400///     at an IP-family destination (non-NULL `msg_name`).
1401///
1402/// Returns the byte count returned by `sendmsg`, or an errno suitable
1403/// for `NotifAction::Errno`. ECONNREFUSED is used both for "destination
1404/// blocked by policy" and for "couldn't parse a port from the
1405/// sockaddr"; EIO for sub-buffer read failures (iovec / control).
1406async fn send_msghdr_on_behalf(
1407    notif: &SeccompNotif,
1408    ctx: &Arc<SupervisorCtx>,
1409    notif_fd: RawFd,
1410    dup_fd: &std::os::unix::io::OwnedFd,
1411    protocol: Protocol,
1412    msghdr_ptr: u64,
1413    flags: i32,
1414) -> Result<isize, i32> {
1415    let msghdr_bytes = match read_child_mem(notif_fd, notif.id, notif.pid, msghdr_ptr, 56) {
1416        Ok(b) if b.len() >= 56 => b,
1417        _ => return Err(libc::EFAULT),
1418    };
1419    let msg_name_ptr = u64::from_ne_bytes(msghdr_bytes[0..8].try_into().unwrap());
1420    let msg_namelen = u32::from_ne_bytes(msghdr_bytes[8..12].try_into().unwrap());
1421    let msg_iov_ptr = u64::from_ne_bytes(msghdr_bytes[16..24].try_into().unwrap());
1422    let msg_iovlen = u64::from_ne_bytes(msghdr_bytes[24..32].try_into().unwrap());
1423    let msg_control_ptr = u64::from_ne_bytes(msghdr_bytes[32..40].try_into().unwrap());
1424    let msg_controllen = u64::from_ne_bytes(msghdr_bytes[40..48].try_into().unwrap());
1425
1426    // A connected socket carries no per-message address (`msg_name == NULL` or
1427    // zero length). There is nothing to check against the destination
1428    // allowlist (the connection was gated at connect time), but we must still
1429    // send it on-behalf rather than Continue: Continue lets the kernel re-read
1430    // the msghdr from child memory, where a racing thread could have swapped a
1431    // null `msg_name` for a denied address. A non-connected entry has its IP
1432    // destination validated on the immune copy before the send.
1433    let connected = msg_name_ptr == 0 || msg_namelen == 0;
1434    let addr_bytes = if connected {
1435        Vec::new()
1436    } else {
1437        match read_child_mem(notif_fd, notif.id, notif.pid, msg_name_ptr, msg_namelen as usize) {
1438            Ok(b) => b,
1439            Err(_) => return Err(libc::EIO),
1440        }
1441    };
1442    if !connected {
1443        let ip = match parse_ip_from_sockaddr(&addr_bytes) {
1444            Some(ip) => ip,
1445            // A non-IP, non-connected address on an IP send path (e.g. the
1446            // sockaddr changed under us). Fail closed.
1447            None => return Err(libc::EAFNOSUPPORT),
1448        };
1449        let dest_port = parse_port_from_sockaddr(&addr_bytes);
1450
1451        let ns = ctx.network.lock().await;
1452        let live_policy = {
1453            let pfs = ctx.policy_fn.lock().await;
1454            pfs.live_policy.clone()
1455        };
1456        let effective = ns.effective_network_policy(notif.pid, protocol, live_policy.as_ref());
1457        if !matches!(effective, crate::seccomp::notif::NetworkPolicy::Unrestricted) {
1458            match dest_port {
1459                Some(p) if !effective.allows(ip, p) => return Err(ECONNREFUSED),
1460                None => return Err(ECONNREFUSED),
1461                Some(_) => {}
1462            }
1463        }
1464        drop(ns);
1465    }
1466
1467    let iovlen = (msg_iovlen as usize).min(1024);
1468    let iov_size = iovlen * 16;
1469    let iov_bytes = match read_child_mem(notif_fd, notif.id, notif.pid, msg_iov_ptr, iov_size) {
1470        Ok(b) => b,
1471        Err(_) => return Err(libc::EIO),
1472    };
1473    let mut data_bufs: Vec<Vec<u8>> = Vec::with_capacity(iovlen);
1474    let mut local_iovs: Vec<libc::iovec> = Vec::with_capacity(iovlen);
1475    for i in 0..iovlen {
1476        let off = i * 16;
1477        if off + 16 > iov_bytes.len() { break; }
1478        let iov_base = u64::from_ne_bytes(iov_bytes[off..off + 8].try_into().unwrap());
1479        let iov_len = u64::from_ne_bytes(iov_bytes[off + 8..off + 16].try_into().unwrap()) as usize;
1480        if iov_len > MAX_SEND_BUF {
1481            return Err(libc::EMSGSIZE);
1482        }
1483        if iov_base == 0 || iov_len == 0 {
1484            data_bufs.push(Vec::new());
1485            continue;
1486        }
1487        let buf = match read_child_mem(notif_fd, notif.id, notif.pid, iov_base, iov_len) {
1488            Ok(b) => b,
1489            Err(_) => return Err(libc::EIO),
1490        };
1491        data_bufs.push(buf);
1492    }
1493    for buf in &data_bufs {
1494        local_iovs.push(libc::iovec {
1495            iov_base: buf.as_ptr() as *mut libc::c_void,
1496            iov_len: buf.len(),
1497        });
1498    }
1499
1500    let control_buf = if msg_control_ptr != 0 && msg_controllen > 0 {
1501        let len = (msg_controllen as usize).min(4096);
1502        read_child_mem(notif_fd, notif.id, notif.pid, msg_control_ptr, len).ok()
1503    } else {
1504        None
1505    };
1506
1507    let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
1508    if !connected {
1509        msg.msg_name = addr_bytes.as_ptr() as *mut libc::c_void;
1510        msg.msg_namelen = addr_bytes.len() as u32;
1511    }
1512    msg.msg_iov = local_iovs.as_mut_ptr();
1513    msg.msg_iovlen = local_iovs.len();
1514    if let Some(ref ctrl) = control_buf {
1515        msg.msg_control = ctrl.as_ptr() as *mut libc::c_void;
1516        msg.msg_controllen = ctrl.len();
1517    }
1518
1519    let ret = unsafe { libc::sendmsg(dup_fd.as_raw_fd(), &msg, flags) };
1520    if ret >= 0 {
1521        Ok(ret)
1522    } else {
1523        Err(unsafe { *libc::__errno_location() })
1524    }
1525}
1526
1527// ============================================================
1528// sendmmsg_on_behalf — multi-message variant
1529// ============================================================
1530
1531/// `struct mmsghdr` size on Linux x86_64 / aarch64: 56-byte msghdr +
1532/// 4-byte msg_len + 4-byte tail padding = 64 bytes. msg_len lives at
1533/// offset 56.
1534const MMSGHDR_SIZE: usize = 64;
1535const MSG_LEN_OFFSET: usize = 56;
1536/// Cap on the number of messages we'll process per sendmmsg call.
1537/// Linux's UIO_MAXIOV is 1024; lower here to bound supervisor work
1538/// per syscall (each entry costs at minimum a few read_child_mem
1539/// hops + one sendmsg).
1540const MAX_MMSGHDR_ENTRIES: usize = 256;
1541
1542/// Perform `sendmmsg()` on behalf of the child. Pre-scans every entry
1543/// for Continue cases (NULL `msg_name` or non-IP family) — if any
1544/// entry would Continue, we Continue the whole syscall to match
1545/// `sendmsg_on_behalf`'s coarse-grained behavior. Otherwise dup the
1546/// child fd once, query SO_PROTOCOL once, then loop:
1547/// validate → send → write `msg_len` back to the child's mmsghdr.
1548///
1549/// On partial failure (entry K denied or send fails), returns
1550/// `ReturnValue(K)` matching the kernel's "messages successfully
1551/// transmitted" semantics. Returns the errno only when the very first
1552/// entry fails — otherwise the child sees a positive count and reads
1553/// per-entry `msg_len` to learn the per-message status.
1554async fn sendmmsg_on_behalf(
1555    notif: &SeccompNotif,
1556    ctx: &Arc<SupervisorCtx>,
1557    notif_fd: RawFd,
1558) -> NotifAction {
1559    let args = &notif.data.args;
1560    let sockfd = args[0] as i32;
1561    let msgvec_ptr = args[1];
1562    let vlen = (args[2] as u32 as usize).min(MAX_MMSGHDR_ENTRIES);
1563    let flags = args[3] as i32;
1564
1565    if vlen == 0 {
1566        return NotifAction::ReturnValue(0);
1567    }
1568
1569    // Named-unix gate. If any entry targets a named AF_UNIX socket, handle the
1570    // whole batch here: the existing prescan below would Continue the entire
1571    // call on the first non-IP entry, which would let a unix entry bypass the
1572    // gate.
1573    if ctx.policy.has_unix_fs_gate {
1574        let mut named_unix = false;
1575        for i in 0..vlen {
1576            let entry_ptr = msgvec_ptr + (i * MMSGHDR_SIZE) as u64;
1577            if mmsg_entry_named_unix_path(notif, notif_fd, entry_ptr).is_some() {
1578                named_unix = true;
1579                break;
1580            }
1581        }
1582        if named_unix {
1583            if ctx.policy.chroot_root.is_some() {
1584                // Chroot: lexical check; deny the whole call if any named-unix
1585                // entry is outside the (virtual) write grants.
1586                for i in 0..vlen {
1587                    let entry_ptr = msgvec_ptr + (i * MMSGHDR_SIZE) as u64;
1588                    if let Some(path) = mmsg_entry_named_unix_path(notif, notif_fd, entry_ptr) {
1589                        if !path_under_any(&path, &ctx.policy.chroot_writable) {
1590                            return NotifAction::Errno(libc::EACCES);
1591                        }
1592                    }
1593                }
1594                // All granted: fall through to the existing path.
1595            } else {
1596                return sendmmsg_named_unix_on_behalf(
1597                    notif,
1598                    notif_fd,
1599                    sockfd,
1600                    msgvec_ptr,
1601                    vlen,
1602                    flags,
1603                    &ctx.policy.chroot_writable,
1604                );
1605            }
1606        }
1607    }
1608
1609    // Destination policy active: handle the whole batch on-behalf and never
1610    // Continue. Continue would let the kernel re-read each `msghdr` from child
1611    // memory, where a racing thread could swap a connected (NULL `msg_name`)
1612    // entry for a denied address after our prescan, bypassing the allowlist on
1613    // an unconnected datagram socket. On-behalf sends use the immune copy and
1614    // validate every IP destination, so the verdict is TOCTOU-free.
1615    if ctx.policy.has_net_destination_policy {
1616        let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd) {
1617            Ok(fd) => fd,
1618            Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
1619        };
1620        let protocol = match query_socket_protocol(dup_fd.as_raw_fd()) {
1621            Some(p) => p,
1622            None => return NotifAction::Errno(ECONNREFUSED),
1623        };
1624        let mut sent: usize = 0;
1625        let mut first_errno: Option<i32> = None;
1626        for i in 0..vlen {
1627            let entry_ptr = msgvec_ptr + (i * MMSGHDR_SIZE) as u64;
1628            match send_msghdr_on_behalf(notif, ctx, notif_fd, &dup_fd, protocol, entry_ptr, flags)
1629                .await
1630            {
1631                Ok(n) => {
1632                    let bytes = (n as u32).to_ne_bytes();
1633                    let _ = write_child_mem(
1634                        notif_fd,
1635                        notif.id,
1636                        notif.pid,
1637                        entry_ptr + MSG_LEN_OFFSET as u64,
1638                        &bytes,
1639                    );
1640                    sent += 1;
1641                }
1642                Err(errno) => {
1643                    first_errno = Some(errno);
1644                    break;
1645                }
1646            }
1647        }
1648        return if sent > 0 {
1649            NotifAction::ReturnValue(sent as i64)
1650        } else {
1651            NotifAction::Errno(first_errno.unwrap_or(ECONNREFUSED))
1652        };
1653    }
1654
1655    // No destination policy: the connected fast path is safe (nothing to
1656    // bypass), so Continue is acceptable. Pre-scan every entry; if any has a
1657    // Continue-eligible shape (NULL msg_name or non-IP family), Continue the
1658    // whole sendmmsg. Mixed-shape calls aren't supported because Continue is
1659    // binary at the syscall level.
1660    for i in 0..vlen {
1661        let entry_ptr = msgvec_ptr + (i * MMSGHDR_SIZE) as u64;
1662        match prescan_msghdr(notif, notif_fd, entry_ptr) {
1663            PrescanResult::OnBehalf => continue,
1664            PrescanResult::ContinueWholeCall => return NotifAction::Continue,
1665            PrescanResult::Errno(e) => return NotifAction::Errno(e),
1666        }
1667    }
1668
1669    let dup_fd = match crate::seccomp::notif::dup_fd_from_pid(notif.pid, sockfd) {
1670        Ok(fd) => fd,
1671        Err(e) => return NotifAction::Errno(e.raw_os_error().unwrap_or(libc::EBADF)),
1672    };
1673    let protocol = match query_socket_protocol(dup_fd.as_raw_fd()) {
1674        Some(p) => p,
1675        None => return NotifAction::Errno(ECONNREFUSED),
1676    };
1677
1678    let mut sent: usize = 0;
1679    let mut first_errno: Option<i32> = None;
1680
1681    for i in 0..vlen {
1682        let entry_ptr = msgvec_ptr + (i * MMSGHDR_SIZE) as u64;
1683        match send_msghdr_on_behalf(notif, ctx, notif_fd, &dup_fd, protocol, entry_ptr, flags).await {
1684            Ok(n) => {
1685                let bytes = (n as u32).to_ne_bytes();
1686                let _ = write_child_mem(
1687                    notif_fd, notif.id, notif.pid,
1688                    entry_ptr + MSG_LEN_OFFSET as u64,
1689                    &bytes,
1690                );
1691                sent += 1;
1692            }
1693            Err(errno) => {
1694                first_errno = Some(errno);
1695                break;
1696            }
1697        }
1698    }
1699
1700    if sent > 0 {
1701        NotifAction::ReturnValue(sent as i64)
1702    } else {
1703        // Defensive: vlen > 0 + no successes means at least one attempt
1704        // failed, so first_errno is set. Fall back to ECONNREFUSED
1705        // rather than panicking on the unwrap if invariants ever drift.
1706        NotifAction::Errno(first_errno.unwrap_or(ECONNREFUSED))
1707    }
1708}
1709
1710// ============================================================
1711// handle_net — main handler for connect/sendto/sendmsg
1712// ============================================================
1713
1714/// Handle network-related notifications (connect, sendto, sendmsg).
1715///
1716/// All three are handled on-behalf (TOCTOU-safe): the supervisor copies data
1717/// from child memory, validates the destination, duplicates the socket via
1718/// pidfd_getfd, and performs the syscall itself. The child's memory is never
1719/// re-read by the kernel after validation.
1720///
1721/// Continue safety (issue #27): the on-behalf paths don't return Continue
1722/// at all (they return ReturnValue/Errno after performing the syscall in
1723/// the supervisor). The Continue cases in this module are:
1724///   1. Non-IP families (AF_UNIX etc.) — the IP allowlist doesn't apply;
1725///      Landlock IPC scoping is the enforcement boundary.
1726///   2. Connected sockets with addr_ptr == 0 — the address was already
1727///      validated at connect time, so the kernel re-read of (nothing) is
1728///      moot.
1729///   3. The fall-through case below — only reachable if the BPF filter
1730///      mis-routes a syscall; the kernel handles it normally.
1731/// In sendmsg_on_behalf, the msghdr read failure path returns
1732/// Errno(EFAULT) rather than Continue: a racing thread that briefly
1733/// unmaps the msghdr could otherwise force a fall-through that lets the
1734/// kernel execute sendmsg without the allowlist check. Sub-buffer read
1735/// failures (sockaddr/iovec/control) already return Errno(EIO) and so
1736/// don't bypass the check either.
1737pub(crate) async fn handle_net(
1738    notif: &SeccompNotif,
1739    ctx: &Arc<SupervisorCtx>,
1740    notif_fd: RawFd,
1741) -> NotifAction {
1742    let nr = notif.data.nr as i64;
1743
1744    if nr == libc::SYS_connect {
1745        connect_on_behalf(notif, ctx, notif_fd).await
1746    } else if nr == libc::SYS_sendto {
1747        sendto_on_behalf(notif, ctx, notif_fd).await
1748    } else if nr == libc::SYS_sendmsg {
1749        sendmsg_on_behalf(notif, ctx, notif_fd).await
1750    } else if nr == libc::SYS_sendmmsg {
1751        sendmmsg_on_behalf(notif, ctx, notif_fd).await
1752    } else {
1753        NotifAction::Continue
1754    }
1755}
1756
1757// ============================================================
1758// resolve_net_allow — resolve --net-allow rules to runtime allowlist
1759// ============================================================
1760
1761/// Resolved form of `Policy::net_allow`, ready for the on-behalf path.
1762pub struct ResolvedNetAllow {
1763    /// Per-IP port rules (each concrete-host entry resolves to one or
1764    /// more IPs). An IP appearing here with an empty port set means
1765    /// "all ports for this IP" (from a `host:*` rule).
1766    pub per_ip: HashMap<IpAddr, HashSet<u16>>,
1767    /// IPs permitted on every port (from `host:*` rules after host
1768    /// resolution). The on-behalf path treats these the same as
1769    /// `PortAllow::Any` — the entry in `per_ip` is kept as a
1770    /// placeholder for diagnostic / `/etc/hosts` purposes.
1771    pub per_ip_all_ports: HashSet<IpAddr>,
1772    /// IP/CIDR-literal targets, matched by containment with no DNS (an
1773    /// exact IP literal is a `/32` or `/128`). Each carries the ports
1774    /// permitted to that range (`PortAllow::Any` for all-ports rules).
1775    pub cidrs: Vec<(IpCidr, crate::seccomp::notif::PortAllow)>,
1776    /// Ports permitted to any IP (the `:port` form).
1777    pub any_ip_ports: HashSet<u16>,
1778    /// Any-host any-port wildcard (`:*` / `*:*`, or `icmp://*`). When
1779    /// true, the per-protocol policy becomes `Unrestricted` and the
1780    /// on-behalf check is bypassed for that protocol.
1781    pub any_ip_all_ports: bool,
1782}
1783
1784/// Per-protocol resolved allowlists. Each protocol gets its own
1785/// `ResolvedNetAllow`; the on-behalf path picks the right one based on
1786/// the dup'd fd's `SO_PROTOCOL`. `etc_hosts` is shared across all
1787/// protocols (the synthetic file maps every concrete host that appears
1788/// in any rule).
1789pub struct ResolvedNetAllowSet {
1790    pub tcp: ResolvedNetAllow,
1791    pub udp: ResolvedNetAllow,
1792    pub icmp: ResolvedNetAllow,
1793    /// `<ip> <hostname>\n` lines from every concrete-host rule across
1794    /// every protocol, in resolution order. Empty when no concrete-host
1795    /// rules are present. Combined with the loopback base (or, in chroot
1796    /// mode, the image's `/etc/hosts`) by [`compose_virtual_etc_hosts`]
1797    /// to build the synthetic file served to the sandbox.
1798    pub concrete_host_entries: String,
1799}
1800
1801/// Resolve `--net-allow` rules into per-protocol runtime allowlists.
1802///
1803/// Rules are grouped by `Protocol` and each group is resolved
1804/// independently. ICMP rules carry no ports, so the resulting ICMP
1805/// `ResolvedNetAllow` always has empty `any_ip_ports` / per-IP port
1806/// sets — the on-behalf check routes ICMP through the IP-only path
1807/// (PortAllow::Any). A `*` host on ICMP becomes `any_ip_all_ports`,
1808/// which the handler reads as "no destination check."
1809pub async fn resolve_net_allow(
1810    rules: &[NetAllow],
1811) -> io::Result<ResolvedNetAllowSet> {
1812    use crate::seccomp::notif::PortAllow;
1813    let per_proto = |target: Protocol| async move {
1814        let mut per_ip: HashMap<IpAddr, HashSet<u16>> = HashMap::new();
1815        let mut per_ip_all_ports: HashSet<IpAddr> = HashSet::new();
1816        let mut cidrs: Vec<(IpCidr, PortAllow)> = Vec::new();
1817        let mut any_ip_ports: HashSet<u16> = HashSet::new();
1818        let mut any_ip_all_ports = false;
1819        let mut local_etc_hosts = String::new();
1820
1821        for rule in rules.iter().filter(|r| r.protocol == target) {
1822            match &rule.target {
1823                NetTarget::AnyIp => {
1824                    if rule.all_ports || target == Protocol::Icmp {
1825                        // ICMP rules never carry ports, so a wildcard-host
1826                        // ICMP rule (`icmp://*`) means "any destination."
1827                        any_ip_all_ports = true;
1828                    } else {
1829                        for &p in &rule.ports {
1830                            any_ip_ports.insert(p);
1831                        }
1832                    }
1833                }
1834                NetTarget::Cidr(c) => {
1835                    // IP/CIDR literals are matched by containment with no
1836                    // DNS, exactly like `--net-deny` targets.
1837                    let pa = if rule.all_ports || target == Protocol::Icmp {
1838                        PortAllow::Any
1839                    } else {
1840                        PortAllow::Specific(rule.ports.iter().copied().collect())
1841                    };
1842                    cidrs.push((*c, pa));
1843                }
1844                NetTarget::Host(host) => {
1845                    let addr = format!("{}:0", host);
1846                    let resolved = tokio::net::lookup_host(addr.as_str()).await.map_err(|e| {
1847                        io::Error::new(
1848                            e.kind(),
1849                            format!("failed to resolve host '{}': {}", host, e),
1850                        )
1851                    })?;
1852                    for socket_addr in resolved {
1853                        let ip = socket_addr.ip();
1854                        if rule.all_ports || target == Protocol::Icmp {
1855                            per_ip_all_ports.insert(ip);
1856                            per_ip.entry(ip).or_default();
1857                        } else {
1858                            let entry = per_ip.entry(ip).or_default();
1859                            for &p in &rule.ports {
1860                                entry.insert(p);
1861                            }
1862                        }
1863                        local_etc_hosts.push_str(&format!("{} {}\n", ip, host));
1864                    }
1865                }
1866            }
1867        }
1868
1869        Ok::<_, io::Error>((
1870            ResolvedNetAllow {
1871                per_ip,
1872                per_ip_all_ports,
1873                cidrs,
1874                any_ip_ports,
1875                any_ip_all_ports,
1876            },
1877            local_etc_hosts,
1878        ))
1879    };
1880
1881    let (tcp, tcp_eh) = per_proto(Protocol::Tcp).await?;
1882    let (udp, udp_eh) = per_proto(Protocol::Udp).await?;
1883    let (icmp, icmp_eh) = per_proto(Protocol::Icmp).await?;
1884
1885    let mut concrete_host_entries = String::new();
1886    for chunk in [tcp_eh, udp_eh, icmp_eh] {
1887        concrete_host_entries.push_str(&chunk);
1888    }
1889
1890    Ok(ResolvedNetAllowSet {
1891        tcp,
1892        udp,
1893        icmp,
1894        concrete_host_entries,
1895    })
1896}
1897
1898/// Per-protocol resolved deny policies, ready for `NetworkState`.
1899pub struct ResolvedNetDenySet {
1900    pub tcp: crate::seccomp::notif::NetworkPolicy,
1901    pub udp: crate::seccomp::notif::NetworkPolicy,
1902    pub icmp: crate::seccomp::notif::NetworkPolicy,
1903}
1904
1905/// Resolve `--net-deny` rules into per-protocol `DenyList` policies.
1906/// A protocol with no deny rules stays `Unrestricted` (allow-all).
1907pub fn resolve_net_deny(rules: &[NetDeny]) -> ResolvedNetDenySet {
1908    use crate::seccomp::notif::{NetworkPolicy, PortAllow};
1909
1910    let per_proto = |target: Protocol| -> NetworkPolicy {
1911        let mut cidrs: Vec<(IpCidr, PortAllow)> = Vec::new();
1912        let mut any_ip_ports: HashSet<u16> = HashSet::new();
1913        let mut deny_all = false;
1914        let mut saw_rule = false;
1915
1916        for rule in rules.iter().filter(|r| r.protocol == target) {
1917            saw_rule = true;
1918            match &rule.target {
1919                NetTarget::AnyIp => {
1920                    if rule.all_ports || target == Protocol::Icmp {
1921                        deny_all = true;
1922                    } else {
1923                        for &p in &rule.ports {
1924                            any_ip_ports.insert(p);
1925                        }
1926                    }
1927                }
1928                NetTarget::Cidr(c) => {
1929                    let pa = if rule.all_ports || target == Protocol::Icmp {
1930                        PortAllow::Any
1931                    } else {
1932                        PortAllow::Specific(rule.ports.iter().copied().collect())
1933                    };
1934                    cidrs.push((*c, pa));
1935                }
1936                // `--net-deny` rejects hostnames at parse time, so a deny
1937                // rule never carries a `Host` target.
1938                NetTarget::Host(_) => unreachable!("net-deny rejects hostnames"),
1939            }
1940        }
1941
1942        if !saw_rule {
1943            NetworkPolicy::Unrestricted
1944        } else {
1945            NetworkPolicy::DenyList {
1946                cidrs,
1947                any_ip_ports,
1948                deny_all,
1949            }
1950        }
1951    };
1952
1953    ResolvedNetDenySet {
1954        tcp: per_proto(Protocol::Tcp),
1955        udp: per_proto(Protocol::Udp),
1956        icmp: per_proto(Protocol::Icmp),
1957    }
1958}
1959
1960/// Compose the synthetic `/etc/hosts` served to the sandbox.
1961///
1962/// - **No chroot**: emit the fixed loopback base
1963///   (`127.0.0.1 localhost\n::1 localhost\n`) followed by the
1964///   concrete-host entries from [`resolve_net_allow`]. The sandbox sees
1965///   the same baseline regardless of what the host's on-disk file says.
1966/// - **With chroot**: read `<chroot>/etc/hosts` and use it as the base
1967///   (an image that bakes in private-registry entries or similar keeps
1968///   them). Inject loopback entries only for any localhost family the
1969///   image doesn't already cover — never both, so we don't duplicate
1970///   what the image already has. Concrete-host entries are still
1971///   appended on top.
1972///
1973/// If a chroot is set but `<chroot>/etc/hosts` is unreadable (absent,
1974/// permission denied, etc.), fall back to the bare loopback base — the
1975/// sandbox always sees a usable hosts file.
1976pub fn compose_virtual_etc_hosts(
1977    chroot_root: Option<&std::path::Path>,
1978    concrete_host_entries: &str,
1979) -> String {
1980    let mut out = String::new();
1981    let mut has_v4_localhost = false;
1982    let mut has_v6_localhost = false;
1983
1984    if let Some(root) = chroot_root {
1985        if let Ok(image) = std::fs::read_to_string(root.join("etc").join("hosts")) {
1986            for line in image.lines() {
1987                // Strip an inline `#` comment before tokenizing — the
1988                // hosts(5) format treats everything after `#` as a comment.
1989                let stripped = line.split('#').next().unwrap_or("");
1990                let mut parts = stripped.split_whitespace();
1991                let Some(ip) = parts.next() else { continue };
1992                for name in parts {
1993                    if name == "localhost" {
1994                        if ip == "127.0.0.1" {
1995                            has_v4_localhost = true;
1996                        } else if ip == "::1" {
1997                            has_v6_localhost = true;
1998                        }
1999                    }
2000                }
2001            }
2002            out.push_str(&image);
2003            if !out.is_empty() && !out.ends_with('\n') {
2004                out.push('\n');
2005            }
2006        }
2007    }
2008
2009    if !has_v4_localhost {
2010        out.push_str("127.0.0.1 localhost\n");
2011    }
2012    if !has_v6_localhost {
2013        out.push_str("::1 localhost\n");
2014    }
2015    out.push_str(concrete_host_entries);
2016    out
2017}
2018
2019// ============================================================
2020// Tests
2021// ============================================================
2022
2023#[cfg(test)]
2024mod tests {
2025    use super::*;
2026
2027    // --- NetAllow::parse tests ---
2028
2029    #[test]
2030    fn netallow_parse_concrete_host_port() {
2031        let r = NetRule::parse_allow("example.com:443").unwrap();
2032        assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com"));
2033        assert_eq!(r.ports, vec![443]);
2034        assert!(!r.all_ports);
2035    }
2036
2037    #[test]
2038    fn netallow_parse_any_host_port() {
2039        let r = NetRule::parse_allow(":8080").unwrap();
2040        assert_eq!(r.target, NetTarget::AnyIp);
2041        assert_eq!(r.ports, vec![8080]);
2042        assert!(!r.all_ports);
2043
2044        let r = NetRule::parse_allow("*:8080").unwrap();
2045        assert_eq!(r.target, NetTarget::AnyIp);
2046        assert_eq!(r.ports, vec![8080]);
2047        assert!(!r.all_ports);
2048    }
2049
2050    #[test]
2051    fn netallow_parse_multiple_ports() {
2052        let r = NetRule::parse_allow("github.com:22,80,443").unwrap();
2053        assert!(matches!(&r.target, NetTarget::Host(h) if h == "github.com"));
2054        assert_eq!(r.ports, vec![22, 80, 443]);
2055        assert!(!r.all_ports);
2056    }
2057
2058    #[test]
2059    fn netallow_parse_wildcard_any_host_any_port_colon() {
2060        let r = NetRule::parse_allow(":*").unwrap();
2061        assert_eq!(r.target, NetTarget::AnyIp);
2062        assert!(r.ports.is_empty());
2063        assert!(r.all_ports);
2064    }
2065
2066    #[test]
2067    fn netallow_parse_wildcard_any_host_any_port_star() {
2068        let r = NetRule::parse_allow("*:*").unwrap();
2069        assert_eq!(r.target, NetTarget::AnyIp);
2070        assert!(r.ports.is_empty());
2071        assert!(r.all_ports);
2072    }
2073
2074    #[test]
2075    fn netallow_parse_wildcard_concrete_host_any_port() {
2076        let r = NetRule::parse_allow("example.com:*").unwrap();
2077        assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com"));
2078        assert!(r.ports.is_empty());
2079        assert!(r.all_ports);
2080    }
2081
2082    #[test]
2083    fn netallow_parse_rejects_mixed_wildcard_and_concrete() {
2084        // `host:80,*` and `host:*,80` are both ambiguous: the user
2085        // either meant "any port" (wildcard wins) or "ports 80 plus
2086        // some weird placeholder". Refuse and force a clean spec.
2087        let err = NetRule::parse_allow("example.com:80,*").unwrap_err();
2088        assert!(format!("{}", err).contains("cannot mix"));
2089        let err = NetRule::parse_allow("example.com:*,80").unwrap_err();
2090        assert!(format!("{}", err).contains("cannot mix"));
2091    }
2092
2093    #[test]
2094    fn netallow_parse_rejects_port_zero() {
2095        let err = NetRule::parse_allow("example.com:0").unwrap_err();
2096        assert!(format!("{}", err).contains("port 0"));
2097    }
2098
2099    #[test]
2100    fn netallow_parse_rejects_empty_port() {
2101        let err = NetRule::parse_allow("example.com:").unwrap_err();
2102        assert!(format!("{}", err).contains("invalid port"));
2103    }
2104
2105    #[test]
2106    fn netallow_bare_host_is_all_ports() {
2107        // No port suffix means "all ports" (port optional), symmetric
2108        // with the `host:*` form.
2109        let r = NetRule::parse_allow("example.com").unwrap();
2110        assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com"));
2111        assert!(r.all_ports);
2112        assert!(r.ports.is_empty());
2113    }
2114
2115    #[test]
2116    fn netallow_bare_star_is_any_host_all_ports() {
2117        let r = NetRule::parse_allow("*").unwrap();
2118        assert_eq!(r.target, NetTarget::AnyIp);
2119        assert!(r.all_ports);
2120        assert!(r.ports.is_empty());
2121    }
2122
2123    #[test]
2124    fn netallow_empty_spec_rejected() {
2125        assert!(NetRule::parse_allow("").is_err());
2126        assert!(NetRule::parse_allow("tcp://").is_err());
2127    }
2128
2129    #[test]
2130    fn netallow_cidr_target_with_port() {
2131        // CIDR ranges are now first-class in --net-allow (matched by
2132        // containment, no DNS), symmetric with --net-deny.
2133        let r = NetRule::parse_allow("10.0.0.0/8:80").unwrap();
2134        assert!(matches!(&r.target, NetTarget::Cidr(c) if !c.is_single_host()));
2135        assert_eq!(r.ports, vec![80]);
2136        assert!(!r.all_ports);
2137    }
2138
2139    #[test]
2140    fn netallow_ipv6_literal_and_bracket() {
2141        let lo: std::net::IpAddr = "::1".parse().unwrap();
2142        // Bare IPv6 literal (previously mis-split on its colons).
2143        let r = NetRule::parse_allow("::1").unwrap();
2144        assert!(matches!(&r.target, NetTarget::Cidr(c) if c.addr == lo && c.is_single_host()));
2145        assert!(r.all_ports);
2146        // Bracketed IPv6 with a port.
2147        let r = NetRule::parse_allow("[::1]:443").unwrap();
2148        assert!(matches!(&r.target, NetTarget::Cidr(c) if c.addr == lo && c.is_single_host()));
2149        assert_eq!(r.ports, vec![443]);
2150        // IPv6 CIDR.
2151        let r = NetRule::parse_allow("fc00::/7").unwrap();
2152        assert!(matches!(&r.target, NetTarget::Cidr(c) if !c.is_single_host()));
2153        assert!(r.all_ports);
2154    }
2155
2156    #[tokio::test]
2157    async fn test_resolve_net_allow_cidr_no_dns() {
2158        // A CIDR / IP-literal target resolves into `cidrs` directly, with
2159        // no DNS lookup and no `per_ip` / `/etc/hosts` entry.
2160        let rules = vec![
2161            NetAllow { protocol: Protocol::Tcp, target: NetTarget::Cidr(IpCidr::parse("10.0.0.0/8").unwrap()), ports: vec![80], all_ports: false },
2162            NetAllow { protocol: Protocol::Tcp, target: NetTarget::Cidr(IpCidr::parse("1.2.3.4").unwrap()), ports: vec![], all_ports: true },
2163        ];
2164        let resolved = resolve_net_allow(&rules).await.unwrap();
2165        assert_eq!(resolved.tcp.cidrs.len(), 2);
2166        assert!(resolved.tcp.per_ip.is_empty());
2167        assert!(resolved.concrete_host_entries.is_empty());
2168    }
2169
2170    #[test]
2171    fn netallow_parse_repeated_wildcard_is_idempotent() {
2172        // `*,*` collapses to a single wildcard — neither token contributes
2173        // a concrete port, so the rule remains "any port".
2174        let r = NetRule::parse_allow(":*,*").unwrap();
2175        assert!(r.all_ports);
2176        assert!(r.ports.is_empty());
2177    }
2178
2179    // --- Protocol scheme prefix tests ---
2180
2181    #[test]
2182    fn netallow_bare_form_defaults_to_tcp() {
2183        let r = NetRule::parse_allow("example.com:443").unwrap();
2184        assert_eq!(r.protocol, Protocol::Tcp);
2185    }
2186
2187    #[test]
2188    fn netallow_explicit_tcp_scheme() {
2189        let r = NetRule::parse_allow("tcp://example.com:443").unwrap();
2190        assert_eq!(r.protocol, Protocol::Tcp);
2191        assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com"));
2192        assert_eq!(r.ports, vec![443]);
2193    }
2194
2195    #[test]
2196    fn netallow_udp_scheme_with_host_port() {
2197        let r = NetRule::parse_allow("udp://1.1.1.1:53").unwrap();
2198        assert_eq!(r.protocol, Protocol::Udp);
2199        // An IP literal becomes a single-host CIDR target (no DNS).
2200        let one: std::net::IpAddr = "1.1.1.1".parse().unwrap();
2201        assert!(matches!(&r.target, NetTarget::Cidr(c) if c.addr == one && c.is_single_host()));
2202        assert_eq!(r.ports, vec![53]);
2203    }
2204
2205    #[test]
2206    fn netallow_udp_wildcard_any_anywhere() {
2207        // The "any UDP" gate, equivalent to the old `allow_udp = true`.
2208        let r = NetRule::parse_allow("udp://*:*").unwrap();
2209        assert_eq!(r.protocol, Protocol::Udp);
2210        assert_eq!(r.target, NetTarget::AnyIp);
2211        assert!(r.all_ports);
2212    }
2213
2214    #[test]
2215    fn netallow_icmp_scheme_with_host() {
2216        let r = NetRule::parse_allow("icmp://github.com").unwrap();
2217        assert_eq!(r.protocol, Protocol::Icmp);
2218        assert!(matches!(&r.target, NetTarget::Host(h) if h == "github.com"));
2219        assert!(r.ports.is_empty());
2220        // ICMP carries no ports, so the rule is "all ports" by convention.
2221        assert!(r.all_ports);
2222    }
2223
2224    #[test]
2225    fn netallow_icmp_wildcard() {
2226        // The "any ICMP echo" gate, equivalent to the old
2227        // `allow_icmp = true` for the SOCK_DGRAM path.
2228        let r = NetRule::parse_allow("icmp://*").unwrap();
2229        assert_eq!(r.protocol, Protocol::Icmp);
2230        assert_eq!(r.target, NetTarget::AnyIp);
2231    }
2232
2233    #[test]
2234    fn netallow_icmp_rejects_port() {
2235        // ICMP has no port — `:port` is meaningless and refused
2236        // explicitly so users can't write a rule that doesn't do what
2237        // they think.
2238        let err = NetRule::parse_allow("icmp://github.com:80").unwrap_err();
2239        assert!(format!("{}", err).contains("icmp rule takes no port"));
2240    }
2241
2242    #[test]
2243    fn netallow_icmp_rejects_empty_body() {
2244        let err = NetRule::parse_allow("icmp://").unwrap_err();
2245        assert!(format!("{}", err).contains("needs a host/IP or `*`"));
2246    }
2247
2248    #[test]
2249    fn netallow_unknown_scheme_rejected() {
2250        // Including `icmp-raw` — sandlock does not expose raw ICMP, so
2251        // the scheme is unknown rather than a special-case error.
2252        for spec in ["sctp://host:1234", "icmp-raw://*"] {
2253            let err = NetRule::parse_allow(spec).unwrap_err();
2254            assert!(format!("{}", err).contains("unknown scheme"), "spec: {}", spec);
2255        }
2256    }
2257
2258    #[tokio::test]
2259    async fn test_resolve_net_allow_empty() {
2260        let resolved = resolve_net_allow(&[]).await.unwrap();
2261        assert!(resolved.tcp.per_ip.is_empty());
2262        assert!(resolved.tcp.any_ip_ports.is_empty());
2263        assert!(resolved.udp.per_ip.is_empty());
2264        assert!(resolved.icmp.per_ip.is_empty());
2265        // No concrete-host rules → no resolved-entry lines.
2266        assert!(resolved.concrete_host_entries.is_empty());
2267    }
2268
2269    #[tokio::test]
2270    async fn test_resolve_net_allow_concrete_host() {
2271        let rules = vec![NetAllow {
2272            protocol: Protocol::Tcp,
2273            target: NetTarget::Host("localhost".to_string()),
2274            ports: vec![80, 443],
2275            all_ports: false,
2276        }];
2277        let resolved = resolve_net_allow(&rules).await.unwrap();
2278        // localhost should resolve to at least one loopback addr; only
2279        // the TCP set has entries.
2280        assert!(!resolved.tcp.per_ip.is_empty());
2281        for ports in resolved.tcp.per_ip.values() {
2282            assert!(ports.contains(&80));
2283            assert!(ports.contains(&443));
2284        }
2285        assert!(resolved.udp.per_ip.is_empty());
2286        assert!(resolved.icmp.per_ip.is_empty());
2287        // The resolved entry (`<ip> localhost`) surfaces in concrete_host_entries.
2288        assert!(resolved.concrete_host_entries.contains("127.0.0.1 localhost"));
2289    }
2290
2291    #[tokio::test]
2292    async fn test_resolve_net_allow_any_ip() {
2293        let rules = vec![NetAllow {
2294            protocol: Protocol::Tcp,
2295            target: NetTarget::AnyIp,
2296            ports: vec![8080],
2297            all_ports: false,
2298        }];
2299        let resolved = resolve_net_allow(&rules).await.unwrap();
2300        assert!(resolved.tcp.per_ip.is_empty());
2301        assert!(resolved.tcp.any_ip_ports.contains(&8080));
2302        assert!(!resolved.tcp.any_ip_all_ports);
2303        // Any-IP rule has no concrete host, so no resolved-entry line.
2304        assert!(resolved.concrete_host_entries.is_empty());
2305    }
2306
2307    #[tokio::test]
2308    async fn test_resolve_net_allow_any_ip_all_ports() {
2309        // `:*` — fully unrestricted egress, TCP-only.
2310        let rules = vec![NetAllow {
2311            protocol: Protocol::Tcp,
2312            target: NetTarget::AnyIp,
2313            ports: vec![],
2314            all_ports: true,
2315        }];
2316        let resolved = resolve_net_allow(&rules).await.unwrap();
2317        assert!(resolved.tcp.any_ip_all_ports);
2318        assert!(resolved.tcp.per_ip.is_empty());
2319        assert!(resolved.tcp.per_ip_all_ports.is_empty());
2320        assert!(resolved.tcp.any_ip_ports.is_empty());
2321        // UDP/ICMP unaffected by a TCP rule.
2322        assert!(!resolved.udp.any_ip_all_ports);
2323        assert!(!resolved.icmp.any_ip_all_ports);
2324    }
2325
2326    #[tokio::test]
2327    async fn test_resolve_net_allow_concrete_host_all_ports() {
2328        // `localhost:*` — every port to localhost only, TCP.
2329        let rules = vec![NetAllow {
2330            protocol: Protocol::Tcp,
2331            target: NetTarget::Host("localhost".to_string()),
2332            ports: vec![],
2333            all_ports: true,
2334        }];
2335        let resolved = resolve_net_allow(&rules).await.unwrap();
2336        assert!(!resolved.tcp.any_ip_all_ports);
2337        assert!(
2338            !resolved.tcp.per_ip_all_ports.is_empty(),
2339            "localhost should resolve to at least one IP marked as any-port"
2340        );
2341        for ip in resolved.tcp.per_ip_all_ports.iter() {
2342            assert!(resolved.tcp.per_ip.contains_key(ip));
2343        }
2344        assert!(resolved.concrete_host_entries.contains("localhost"));
2345    }
2346
2347    #[tokio::test]
2348    async fn test_resolve_net_allow_mixed_wildcard_and_concrete() {
2349        // Wildcard rule alongside concrete: wildcard sets the global
2350        // any-host any-port flag for TCP; concrete rule still resolves
2351        // into per_ip (the runtime layer chooses Unrestricted, ignoring
2352        // the concrete entries).
2353        let rules = vec![
2354            NetAllow {
2355                protocol: Protocol::Tcp,
2356                target: NetTarget::AnyIp,
2357                ports: vec![],
2358                all_ports: true,
2359            },
2360            NetAllow {
2361                protocol: Protocol::Tcp,
2362                target: NetTarget::Host("localhost".to_string()),
2363                ports: vec![22],
2364                all_ports: false,
2365            },
2366        ];
2367        let resolved = resolve_net_allow(&rules).await.unwrap();
2368        assert!(resolved.tcp.any_ip_all_ports);
2369        assert!(!resolved.tcp.per_ip.is_empty());
2370    }
2371
2372    // ============================================================
2373    // Per-protocol resolution — UDP / ICMP slices stay isolated
2374    // ============================================================
2375
2376    #[tokio::test]
2377    async fn test_resolve_per_protocol_isolation() {
2378        // A UDP rule should not appear in the TCP set, and vice versa.
2379        // This is the property Phase 2 relies on for protocol routing.
2380        let rules = vec![
2381            NetAllow {
2382                protocol: Protocol::Tcp,
2383                target: NetTarget::Host("localhost".to_string()),
2384                ports: vec![443],
2385                all_ports: false,
2386            },
2387            NetAllow {
2388                protocol: Protocol::Udp,
2389                target: NetTarget::AnyIp,
2390                ports: vec![53],
2391                all_ports: false,
2392            },
2393        ];
2394        let resolved = resolve_net_allow(&rules).await.unwrap();
2395        assert!(
2396            !resolved.tcp.per_ip.is_empty(),
2397            "TCP rule should populate tcp set"
2398        );
2399        assert!(
2400            resolved.udp.any_ip_ports.contains(&53),
2401            "UDP rule should populate udp set"
2402        );
2403        // Cross-contamination check: TCP per_ip ports must not contain 53;
2404        // UDP must not contain 443.
2405        for ports in resolved.tcp.per_ip.values() {
2406            assert!(!ports.contains(&53), "UDP port leaked into TCP set");
2407        }
2408        assert!(!resolved.udp.any_ip_ports.contains(&443), "TCP port leaked into UDP set");
2409    }
2410
2411    #[tokio::test]
2412    async fn test_resolve_icmp_no_ports() {
2413        // ICMP rules carry no ports; concrete hosts go into per_ip with
2414        // PortAllow::Any-style empty port set, plus per_ip_all_ports.
2415        let rules = vec![NetAllow {
2416            protocol: Protocol::Icmp,
2417            target: NetTarget::Host("localhost".to_string()),
2418            ports: vec![],
2419            all_ports: false,
2420        }];
2421        let resolved = resolve_net_allow(&rules).await.unwrap();
2422        assert!(
2423            !resolved.icmp.per_ip.is_empty(),
2424            "icmp host should populate per_ip"
2425        );
2426        assert!(
2427            !resolved.icmp.per_ip_all_ports.is_empty(),
2428            "icmp host should mark per_ip_all_ports (no port check)"
2429        );
2430        assert!(resolved.icmp.any_ip_ports.is_empty());
2431        // TCP/UDP unaffected.
2432        assert!(resolved.tcp.per_ip.is_empty());
2433        assert!(resolved.udp.per_ip.is_empty());
2434    }
2435
2436    #[tokio::test]
2437    async fn test_resolve_icmp_wildcard() {
2438        // `icmp://*` — any ICMP destination.
2439        let rules = vec![NetAllow {
2440            protocol: Protocol::Icmp,
2441            target: NetTarget::AnyIp,
2442            ports: vec![],
2443            all_ports: false,
2444        }];
2445        let resolved = resolve_net_allow(&rules).await.unwrap();
2446        assert!(resolved.icmp.any_ip_all_ports);
2447        assert!(!resolved.tcp.any_ip_all_ports);
2448    }
2449
2450    // ============================================================
2451    // compose_virtual_etc_hosts — synthetic /etc/hosts assembly
2452    // ============================================================
2453
2454    use std::io::Write;
2455
2456    fn temp_rootfs_with_hosts(name: &str, hosts_content: Option<&str>) -> std::path::PathBuf {
2457        let dir = std::env::temp_dir().join(format!(
2458            "sandlock-test-compose-hosts-{}-{}",
2459            name, std::process::id()
2460        ));
2461        let _ = std::fs::create_dir_all(dir.join("etc"));
2462        if let Some(content) = hosts_content {
2463            let mut f = std::fs::File::create(dir.join("etc").join("hosts")).unwrap();
2464            f.write_all(content.as_bytes()).unwrap();
2465        }
2466        dir
2467    }
2468
2469    #[test]
2470    fn compose_no_chroot_emits_loopback_base() {
2471        // Default path — no chroot, no concrete-host rules → the same
2472        // fixed loopback view we promise every sandbox.
2473        let out = compose_virtual_etc_hosts(None, "");
2474        assert_eq!(out, "127.0.0.1 localhost\n::1 localhost\n");
2475    }
2476
2477    #[test]
2478    fn compose_no_chroot_appends_concrete_entries() {
2479        let out = compose_virtual_etc_hosts(None, "10.0.0.1 api\n");
2480        assert_eq!(out, "127.0.0.1 localhost\n::1 localhost\n10.0.0.1 api\n");
2481    }
2482
2483    #[test]
2484    fn compose_chroot_seeds_from_image_and_injects_missing_loopback() {
2485        // Image ships an entry of its own but no localhost mapping; the
2486        // shim must keep the image's content and inject both loopback
2487        // entries on top so the always-on guarantee still holds.
2488        let rootfs = temp_rootfs_with_hosts(
2489            "no-localhost",
2490            Some("10.0.0.5 myimage.local\n"),
2491        );
2492        let out = compose_virtual_etc_hosts(Some(&rootfs), "");
2493        assert!(out.contains("10.0.0.5 myimage.local"), "image entry missing: {out}");
2494        assert!(out.contains("127.0.0.1 localhost"), "v4 loopback missing: {out}");
2495        assert!(out.contains("::1 localhost"), "v6 loopback missing: {out}");
2496        let _ = std::fs::remove_dir_all(&rootfs);
2497    }
2498
2499    #[test]
2500    fn compose_chroot_does_not_duplicate_existing_loopback() {
2501        // Image already has both loopback entries — don't append duplicates.
2502        let rootfs = temp_rootfs_with_hosts(
2503            "both-localhost",
2504            Some("127.0.0.1 localhost\n::1 localhost\n10.0.0.5 myimage.local\n"),
2505        );
2506        let out = compose_virtual_etc_hosts(Some(&rootfs), "");
2507        assert_eq!(out.matches("127.0.0.1 localhost").count(), 1, "v4 dup'd: {out}");
2508        assert_eq!(out.matches("::1 localhost").count(), 1, "v6 dup'd: {out}");
2509        assert!(out.contains("10.0.0.5 myimage.local"));
2510        let _ = std::fs::remove_dir_all(&rootfs);
2511    }
2512
2513    #[test]
2514    fn compose_chroot_injects_only_missing_family() {
2515        // Image has v4 but no v6 localhost — inject only v6, leave v4 alone.
2516        let rootfs = temp_rootfs_with_hosts(
2517            "only-v4-localhost",
2518            Some("127.0.0.1 localhost myimage\n"),
2519        );
2520        let out = compose_virtual_etc_hosts(Some(&rootfs), "");
2521        assert_eq!(out.matches("127.0.0.1 localhost").count(), 1);
2522        assert!(out.contains("::1 localhost"), "v6 loopback should be injected: {out}");
2523        let _ = std::fs::remove_dir_all(&rootfs);
2524    }
2525
2526    #[test]
2527    fn compose_chroot_missing_file_falls_back_to_loopback() {
2528        // Chroot exists but has no /etc/hosts — fall back to the bare
2529        // loopback base so the sandbox always sees a usable file.
2530        let rootfs = temp_rootfs_with_hosts("no-file", None);
2531        let out = compose_virtual_etc_hosts(Some(&rootfs), "10.0.0.1 api\n");
2532        assert_eq!(out, "127.0.0.1 localhost\n::1 localhost\n10.0.0.1 api\n");
2533        let _ = std::fs::remove_dir_all(&rootfs);
2534    }
2535
2536    #[test]
2537    fn compose_chroot_strips_inline_comments_when_detecting_loopback() {
2538        // hosts(5) treats `#` as a comment-start; the loopback-presence
2539        // check must respect it (otherwise an image line like
2540        // `127.0.0.1 # localhost` would be falsely treated as covering v4).
2541        let rootfs = temp_rootfs_with_hosts(
2542            "with-comments",
2543            Some("127.0.0.1 # localhost is a comment here\n"),
2544        );
2545        let out = compose_virtual_etc_hosts(Some(&rootfs), "");
2546        // Real `127.0.0.1 localhost` line must still be injected.
2547        assert!(
2548            out.lines().any(|l| l.trim() == "127.0.0.1 localhost"),
2549            "v4 loopback should still be injected: {out}"
2550        );
2551        let _ = std::fs::remove_dir_all(&rootfs);
2552    }
2553
2554    // --- IpCidr tests ---
2555
2556    #[test]
2557    fn ipcidr_parse_bare_ipv4_is_host_route() {
2558        let c = IpCidr::parse("1.2.3.4").unwrap();
2559        assert_eq!(c.prefix_len, 32);
2560        assert!(c.contains("1.2.3.4".parse().unwrap()));
2561        assert!(!c.contains("1.2.3.5".parse().unwrap()));
2562    }
2563
2564    #[test]
2565    fn ipcidr_parse_ipv4_range_contains() {
2566        let c = IpCidr::parse("10.0.0.0/8").unwrap();
2567        assert!(c.contains("10.3.7.9".parse().unwrap()));
2568        assert!(!c.contains("11.0.0.1".parse().unwrap()));
2569    }
2570
2571    #[test]
2572    fn ipcidr_parse_ipv6_range_contains() {
2573        let c = IpCidr::parse("fc00::/7").unwrap();
2574        assert!(c.contains("fd00::1".parse().unwrap()));
2575        assert!(!c.contains("2001:db8::1".parse().unwrap()));
2576    }
2577
2578    #[test]
2579    fn ipcidr_zero_prefix_matches_all_same_family() {
2580        let c = IpCidr::parse("0.0.0.0/0").unwrap();
2581        assert!(c.contains("8.8.8.8".parse().unwrap()));
2582        assert!(!c.contains("::1".parse().unwrap())); // family mismatch
2583    }
2584
2585    #[test]
2586    fn ipcidr_rejects_hostname() {
2587        assert!(IpCidr::parse("example.com").is_err());
2588    }
2589
2590    #[test]
2591    fn ipcidr_rejects_oversized_prefix() {
2592        assert!(IpCidr::parse("10.0.0.0/33").is_err());
2593        assert!(IpCidr::parse("fc00::/129").is_err());
2594    }
2595
2596    // --- NetDeny::parse tests ---
2597
2598    #[test]
2599    fn netdeny_bare_cidr_is_all_ports_tcp() {
2600        let rule = NetRule::parse_deny("10.0.0.0/8").unwrap();
2601        assert_eq!(rule.protocol, Protocol::Tcp);
2602        assert!(matches!(rule.target, NetTarget::Cidr(_)));
2603        assert!(rule.all_ports);
2604    }
2605
2606    #[test]
2607    fn netdeny_bare_ip_is_host_route_all_ports() {
2608        let rule = NetRule::parse_deny("169.254.169.254").unwrap();
2609        match &rule.target {
2610            NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 32),
2611            _ => panic!("expected cidr"),
2612        }
2613        assert!(rule.all_ports);
2614    }
2615
2616    #[test]
2617    fn netdeny_cidr_with_port() {
2618        let rule = NetRule::parse_deny("10.0.0.0/8:443").unwrap();
2619        assert_eq!(rule.ports, vec![443]);
2620        assert!(!rule.all_ports);
2621    }
2622
2623    #[test]
2624    fn netdeny_any_ip_port() {
2625        let rule = NetRule::parse_deny(":25").unwrap();
2626        assert!(matches!(rule.target, NetTarget::AnyIp));
2627        assert_eq!(rule.ports, vec![25]);
2628    }
2629
2630    #[test]
2631    fn netdeny_udp_scheme() {
2632        let rule = NetRule::parse_deny("udp://192.168.0.0/16:53").unwrap();
2633        assert_eq!(rule.protocol, Protocol::Udp);
2634        assert_eq!(rule.ports, vec![53]);
2635    }
2636
2637    #[test]
2638    fn netdeny_ipv6_bracket_port() {
2639        let rule = NetRule::parse_deny("[::1]:443").unwrap();
2640        assert_eq!(rule.ports, vec![443]);
2641        match &rule.target {
2642            NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128),
2643            _ => panic!("expected cidr"),
2644        }
2645    }
2646
2647    #[test]
2648    fn netdeny_rejects_hostname() {
2649        assert!(NetRule::parse_deny("evil.com:443").is_err());
2650        assert!(NetRule::parse_deny("evil.com").is_err());
2651    }
2652
2653    #[test]
2654    fn netdeny_bare_ipv6_address_all_ports() {
2655        let rule = NetRule::parse_deny("::1").unwrap();
2656        assert!(rule.all_ports);
2657        match &rule.target {
2658            NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128),
2659            _ => panic!("expected cidr"),
2660        }
2661    }
2662
2663    #[test]
2664    fn netdeny_bare_ipv6_cidr_all_ports() {
2665        let rule = NetRule::parse_deny("fc00::/7").unwrap();
2666        assert!(rule.all_ports);
2667        let ula: std::net::IpAddr = "fd00::1".parse().unwrap();
2668        assert!(matches!(&rule.target, NetTarget::Cidr(c) if c.contains(ula)));
2669    }
2670
2671    #[test]
2672    fn netdeny_empty_icmp_body_is_rejected() {
2673        assert!(NetRule::parse_deny("icmp://").is_err());
2674    }
2675
2676    #[test]
2677    fn netdeny_bare_star_is_any_ip_all_ports() {
2678        // `*` with no port is the any-IP, all-ports form (port optional,
2679        // symmetric with a bare IP/CIDR).
2680        let rule = NetRule::parse_deny("*").unwrap();
2681        assert_eq!(rule.protocol, Protocol::Tcp);
2682        assert!(matches!(rule.target, NetTarget::AnyIp));
2683        assert!(rule.all_ports);
2684        assert!(rule.ports.is_empty());
2685    }
2686
2687    #[test]
2688    fn netdeny_udp_bare_star_all_ports() {
2689        let rule = NetRule::parse_deny("udp://*").unwrap();
2690        assert_eq!(rule.protocol, Protocol::Udp);
2691        assert!(matches!(rule.target, NetTarget::AnyIp));
2692        assert!(rule.all_ports);
2693    }
2694
2695    #[test]
2696    fn netdeny_empty_spec_rejected() {
2697        // An empty body must not silently mean "deny everything".
2698        assert!(NetRule::parse_deny("").is_err());
2699        assert!(NetRule::parse_deny("udp://").is_err());
2700    }
2701
2702    // --- resolve_net_deny tests ---
2703
2704    #[test]
2705    fn resolve_net_deny_groups_per_protocol() {
2706        let rule = NetRule::parse_deny("10.0.0.0/8").unwrap();
2707        let set = resolve_net_deny(std::slice::from_ref(&rule));
2708        // TCP policy denies 10.x, UDP/ICMP unaffected (still allow-all).
2709        assert!(!set.tcp.allows("10.0.0.1".parse().unwrap(), 443));
2710        assert!(set.udp.allows("10.0.0.1".parse().unwrap(), 443));
2711    }
2712
2713    #[test]
2714    fn resolve_net_deny_any_ip_port() {
2715        let rule = NetRule::parse_deny(":25").unwrap();
2716        let set = resolve_net_deny(std::slice::from_ref(&rule));
2717        assert!(!set.tcp.allows("8.8.8.8".parse().unwrap(), 25));
2718        assert!(set.tcp.allows("8.8.8.8".parse().unwrap(), 80));
2719    }
2720}