Skip to main content

capsule_runtime/
network_namespace.rs

1//! The capsule's own network namespace: the structural half of the mechanism that replaced the
2//! seccomp-notify `connect`/`sendto` supervisor.
3//!
4//! [`crate::egress_proxy`] decides *which* destinations a subprocess may reach. This module
5//! is what makes that decision the only way out — it puts the subprocess tree somewhere with no
6//! route off the host, so an unlisted destination is not "denied" by a filter that has to be
7//! consulted, it is simply unreachable.
8//!
9//! ## One namespace per session
10//!
11//! A session has exactly one network namespace, [`SessionNetns`], created the first time one of
12//! its commands is spawned and shared by every command after that. A server one shell call starts
13//! on `127.0.0.1:8080` is therefore reachable from the session's next call, and from nothing
14//! outside the session: two sessions are two `mur` processes, each with its own `SessionNetns`,
15//! and no interface hands one across a process boundary. A plan's shell steps run in the
16//! namespace of the session that submitted the plan.
17//!
18//! The namespace is built once by a dedicated *creator* child, forked for nothing else, which
19//! hands the parent the namespace's listening sockets and a descriptor for each of its three
20//! namespaces — user, network and PID — in one `SCM_RIGHTS` message and then exits. The network
21//! namespace outlives the creator because the runtime holds those descriptors; the PID namespace
22//! outlives it because of its *holder*, pid 1 of that namespace, which the creator forks and which
23//! lives exactly as long as the runtime holds its lifeline. Every command then enters the
24//! namespaces with [`join_session_netns`] — `setns(2)` into the user namespace, then into the
25//! network namespace — and `setns(2)` into the PID namespace followed by a fork, all in its own
26//! `pre_exec` window, where it is single-threaded and still unfiltered (`sandbox::linux_enforce`'s
27//! relay). The runtime's own threads never call `setns` or `unshare`. The namespaces are released
28//! when the session ends: [`SessionNetns::end`] stops the session's egress proxy, closes the
29//! descriptors and closes the holder's lifeline, after `ShellEnforcement::end_session` has killed
30//! the session's cgroup scope; the holder's exit then kills whatever is left in the PID
31//! namespace.
32//!
33//! ## Whether a host can create one
34//!
35//! Answered by creating one: [`crate::namespace_probe`] runs the same creator a session does, with
36//! an empty allowlist, and drops what it hands over. A staged session's staging and launch read
37//! the answer off that session's one [`crate::sandbox::HostProbe`], and a session's first command
38//! gets the same refusal from its own creation ([`SessionNetns::establish`]). AppArmor's
39//! unprivileged-userns restriction and the profile confining `mur` are read only to explain a
40//! failure: a container whose own profile permits the namespace is not refused because the host's
41//! restriction reads `1`.
42//!
43//! ## The syscall sequence
44//!
45//! Everything happens in the creator child, before any capsule code runs, and the join happens in
46//! each command's forked child before the seccomp filter (which denies `unshare`, `setns` and
47//! `socket(AF_NETLINK)` outright) and before [`crate::sealed`]'s composed root:
48//!
49//!   1. `unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID)`, then the identity
50//!      `uid_map`/`gid_map` writes that make the child the owner of all three, then a fork: the
51//!      forked process is pid 1 of the new PID namespace, the holder, and keeps only its lifeline
52//!      (see [`SessionNetns::end`]). The user namespace is not incidental: `CLONE_NEWNET`
53//!      alone needs `CAP_NET_ADMIN`, which an unprivileged process does not have, and creating it
54//!      inside a user namespace it owns is what supplies that capability *there and nowhere else*.
55//!      This is the same primitive `sealed` uses, and AppArmor's unprivileged-userns
56//!      restriction can refuse it the same way.
57//!   2. `lo` is brought up ([`bring_loopback_up`]). It is the namespace's only interface: there is
58//!      no veth pair, no bridge and no NAT, so nothing in the namespace has a path to the host's
59//!      network or to the outside world.
60//!   3. One route is installed over netlink ([`add_local_default_route`]): `local default dev lo`.
61//!      A `local`-type default route makes every address *locally deliverable*, so a `connect()`
62//!      to any address is looped back to a socket in this namespace with the original destination
63//!      intact — recoverable with `getsockname(2)`. That is the entire interception mechanism,
64//!      and it is why no netfilter rule is needed anywhere. It also means a lookup reaches the
65//!      resolver whatever address `/etc/resolv.conf` names, and a connection to a port nothing is
66//!      bound to is refused immediately rather than stalling through the kernel's full SYN-retry
67//!      schedule against a blackhole.
68//!   4. The listening sockets are created here, *inside* the namespace — one TCP listener per
69//!      port the allowlist implies, then one wildcard UDP :53 resolver socket — and handed back
70//!      to the parent in a single `SCM_RIGHTS` message, followed by the `/proc/self/ns/user`,
71//!      `/proc/self/ns/net` and `/proc/self/ns/pid_for_children` descriptors later commands join
72//!      through. A socket belongs to the
73//!      namespace it was **created** in, not the one it is used from, which is the whole reason
74//!      this inversion works: the runtime serves them while staying in the host's namespace, with
75//!      real internet access for the upstream half. The listeners are held for the whole session,
76//!      so a server a command starts on one of those ports gets `EADDRINUSE`.
77//!
78//! ## Why this shape rather than the obvious ones
79//!
80//! *No veth pair.* Wiring a namespace to the host with veth requires `CAP_NET_ADMIN` in the
81//! **host's** network namespace, which means real root. This design needs no host privilege at
82//! all, which is what lets `mur` install per-user.
83//!
84//! *No proxy process inside the namespace, and a holder that forks nothing.* A multithreaded process cannot
85//! enter a user namespace: `setns(user_fd, CLONE_NEWUSER)` from the runtime fails (`EINVAL`, or
86//! `EPERM` from one of its threads). A freshly forked child is single-threaded, and the euid that
87//! owns the session's user namespace holds every capability in it, so the same `setns` from a
88//! command's `pre_exec` window succeeds, followed by `setns(net_fd, CLONE_NEWNET)`. Passing the
89//! listening sockets *out* is what lets the proxy serve the namespace from the host's side with no
90//! privilege at all.
91//!
92//! A long-lived single-threaded holder that forked each command on request was the alternative.
93//! It would make every command its child rather than the runtime's, so every `Child` the shell
94//! path keys on would need a proxy layer, and it would re-implement `std::process::Command`'s spawn
95//! over a request protocol. Joining in `pre_exec` changes one step of the existing spawn instead.
96//! The PID namespace's holder is not that process: it is only pid 1, forks nothing, runs no
97//! capsule code and holds no descriptor but its lifeline. A command still enters the namespace in
98//! its own `pre_exec` window, through a relay: the process `Child` refers to stays outside the
99//! namespace and waits for the command it forks into it.
100//!
101//! *No `iptables`/`nft`/`TPROXY`.* Nothing here needs a netfilter rule, so there is no dependency
102//! on netfilter modules being present, no `iptables` binary to shell out to, and no rule set that
103//! can drift out of step with the manifest.
104//!
105//! ## What this does not isolate
106//!
107//! `AF_UNIX` is untouched. `CLONE_NEWNET` does not mediate unix sockets in any way that matters
108//! here — a pathname socket is reached through the filesystem, not the network stack. That is
109//! exactly why the register-level `socket(2)` domain filter (`denied_socket_domains` in
110//! [`crate::sandbox`]) is untouched here and remains the thing that refuses
111//! `/var/run/docker.sock`.
112
113// The creation steps and the namespace plan stay defined everywhere so the decisions over them are
114// unit-tested on every OS, but outside tests only Linux code builds or reads them, so off Linux
115// they are dead by construction.
116#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
117
118/// One TCP listener per allowlisted port, plus the single wildcard UDP :53 resolver socket.
119pub(crate) const MAX_NAMESPACE_SOCKETS: usize = crate::egress_proxy::MAX_EGRESS_TCP_PORTS + 1;
120
121/// The namespace handles the creator sends after the sockets: `/proc/self/ns/user`,
122/// `/proc/self/ns/net` and `/proc/self/ns/pid_for_children`.
123pub(crate) const NAMESPACE_HANDLE_COUNT: usize = 3;
124
125/// Every descriptor the creator child sends: the namespace sockets, then the user, network and
126/// PID namespace handles.
127///
128/// Bounds the one `SCM_RIGHTS` message, and therefore the fixed stack buffers the creator builds
129/// it in.
130pub(crate) const MAX_SESSION_NETNS_FDS: usize = MAX_NAMESPACE_SOCKETS + NAMESPACE_HANDLE_COUNT;
131
132// ---------------------------------------------------------------- the creation's steps
133
134/// One step of the namespace's construction, as the creator reports it when that step fails.
135///
136/// Defined outside the Linux module so [`egress_namespace_failure`], the decision over a failed
137/// step, compiles and is tested on every OS.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum CreationStep {
140    Unshare = 1,
141    IdentityMaps = 2,
142    Loopback = 3,
143    LocalRoute = 4,
144    TcpListener = 5,
145    Resolver = 6,
146    NamespaceHandles = 7,
147    HandOff = 8,
148    /// The user and network namespaces were created and a PID namespace with them was refused.
149    PidNamespace = 9,
150    /// Forking the PID namespace's first process, the holder, failed.
151    Holder = 10,
152}
153
154impl CreationStep {
155    const ALL: [CreationStep; 10] = [
156        Self::Unshare,
157        Self::IdentityMaps,
158        Self::Loopback,
159        Self::LocalRoute,
160        Self::TcpListener,
161        Self::Resolver,
162        Self::NamespaceHandles,
163        Self::HandOff,
164        Self::PidNamespace,
165        Self::Holder,
166    ];
167
168    /// The step a failure record's code names, or `None` for a code no step carries.
169    pub(crate) fn from_code(code: u32) -> Option<Self> {
170        Self::ALL.into_iter().find(|step| *step as u32 == code)
171    }
172
173    /// The call or operation, as a refusal names it.
174    #[must_use]
175    pub fn describe(self) -> &'static str {
176        match self {
177            Self::Unshare => "unshare(CLONE_NEWUSER | CLONE_NEWNET)",
178            Self::IdentityMaps => "writing uid_map/gid_map",
179            Self::Loopback => "bringing lo up",
180            Self::LocalRoute => "installing the local default route",
181            Self::TcpListener => "binding a TCP listener inside the namespace",
182            Self::Resolver => "binding UDP 53 inside the namespace",
183            Self::NamespaceHandles => {
184                "opening /proc/self/ns/user, /proc/self/ns/net and /proc/self/ns/pid_for_children"
185            }
186            Self::HandOff => "handing the namespace to the runtime",
187            Self::PidNamespace => "unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID)",
188            Self::Holder => "forking the PID namespace's holder process",
189        }
190    }
191}
192
193/// Why an attempt to create a session's network namespace produced no namespace.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum NamespaceAttemptError {
196    /// The creator ran, and `step` failed with `errno`.
197    Step { step: CreationStep, errno: i32 },
198    /// The creator could not run, or exited without handing anything over; the runtime's own
199    /// account of it (`fork() for the namespace creator failed: …`).
200    Creator(String),
201}
202
203// ---------------------------------------------------------------- the failure
204
205/// Which remediation an [`EgressNamespaceFailure`] calls for.
206///
207/// Not an `EnforcementTier` variant and not tied to `ContainmentClass`: a network
208/// namespace is required for every Linux capsule that can spawn a subprocess, at every
209/// containment class, so hanging it off the class ladder would make `advisory` capsules silently
210/// exempt from this boundary.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
212#[serde(rename_all = "snake_case")]
213pub enum EgressNamespaceBlocker {
214    /// The kernel implements unprivileged user namespaces and something on this host refused one
215    /// to `mur`: AppArmor's unprivileged-userns restriction, another AppArmor profile confining
216    /// `mur`, a container runtime's seccomp filter or capabilities, or another security module.
217    /// The refusal came at `unshare`, or after it while the namespace was owned or configured.
218    CapabilityGrantMissing,
219    /// The kernel does not provide the mechanism: `unshare` failed with `ENOSPC`
220    /// (`user.max_user_namespaces`), `EINVAL` or `ENOSYS` (`CONFIG_USER_NS=n`), or `EUSERS` (the
221    /// nesting limit).
222    KernelSupportMissing,
223    /// The runtime could not run the namespace creator, or the creator stopped without reporting
224    /// a step — a process or descriptor limit, memory, or a policy that killed it — so the host's
225    /// answer is unknown. Refused like the other two: a namespace nobody could create is a
226    /// namespace the capsule does not get.
227    CreatorUnavailable,
228}
229
230/// A creation of the capsule network namespace that failed, with what its refusal names.
231///
232/// Every refusal of `E-CAP-005` — at staging, at launch, at a session's first command, in
233/// `mur doctor` — renders [`Self::reason`], so the places that ask cannot explain one host two
234/// ways.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct EgressNamespaceFailure {
237    /// Decided by the failed call and its `errno` alone; the readings below never change it.
238    pub blocker: EgressNamespaceBlocker,
239    /// The call that failed, in [`CreationStep::describe`]'s words (`unshare(CLONE_NEWUSER |
240    /// CLONE_NEWNET)`, `writing uid_map/gid_map`, …), or, for
241    /// [`EgressNamespaceBlocker::CreatorUnavailable`], the runtime's account of why the creator
242    /// could not run.
243    pub failed_call: String,
244    /// The `errno` the failed call left. `None` exactly for
245    /// [`EgressNamespaceBlocker::CreatorUnavailable`], where no creation step ran.
246    pub errno: Option<i32>,
247    /// AppArmor's unprivileged-userns restriction as this process read it when the failure was
248    /// observed. Explains the failure; never decides whether there is one.
249    pub userns_grant: crate::sealed::UsernsGrant,
250    /// The profile the kernel reports as confining this process, read at the same moment.
251    pub attachment: crate::sealed::ProfileAttachment,
252}
253
254impl EgressNamespaceFailure {
255    /// `<failed call> failed with <ERRNO>`, or the creator's account: the one-line answer
256    /// `mur doctor` prints.
257    #[must_use]
258    pub fn summary(&self) -> String {
259        match self.errno {
260            Some(errno) => format!("{} failed with {}", self.failed_call, errno_label(errno)),
261            None => self.failed_call.clone(),
262        }
263    }
264
265    /// The text an operator sees under `E-CAP-005`: what failed, with its `errno` symbolically
266    /// and in words; the likely cause and its remedy; and, last, the host readings the cause was
267    /// chosen from.
268    #[must_use]
269    pub fn reason(&self) -> String {
270        let what = match self.errno {
271            Some(errno) => format!(
272                "{} failed with {} ({})",
273                self.failed_call,
274                errno_label(errno),
275                errno_words(errno)
276            ),
277            None => self.failed_call.clone(),
278        };
279        format!("{what}. {} {}", self.cause(), self.readings())
280    }
281
282    /// The step `failed_call` names, when it names one.
283    fn step(&self) -> Option<CreationStep> {
284        self.errno?;
285        CreationStep::ALL
286            .into_iter()
287            .find(|step| step.describe() == self.failed_call)
288    }
289
290    fn cause(&self) -> String {
291        match self.blocker {
292            EgressNamespaceBlocker::KernelSupportMissing => {
293                kernel_cause(self.step(), self.errno).to_string()
294            }
295            EgressNamespaceBlocker::CreatorUnavailable => {
296                "Likely cause: the namespace creator could not run or stopped before reporting — \
297                 a process or descriptor limit, or memory, ran out, or a policy killed it (its \
298                 account then reads `killed by signal`) — so whether this host can create \
299                 the namespace is unknown, and the capsule is refused rather than run without \
300                 it. Re-run once the limit is relieved, or allow the creator's syscalls."
301                    .to_string()
302            }
303            EgressNamespaceBlocker::CapabilityGrantMissing
304                if self.step() == Some(CreationStep::Holder) =>
305            {
306                format!(
307                    "{} Likely cause: a process limit (RLIMIT_NPROC, or the pids.max of the cgroup \
308                     mur runs in) or memory ran out. Re-run once the limit is relieved.",
309                    step_context(CreationStep::Holder).unwrap_or_default()
310                )
311            }
312            EgressNamespaceBlocker::CapabilityGrantMissing => {
313                let cause = grant_cause(self.userns_grant, &self.attachment);
314                match self.step().and_then(step_context) {
315                    Some(context) => format!("{context} {cause}"),
316                    None => cause,
317                }
318            }
319        }
320    }
321
322    fn readings(&self) -> String {
323        host_readings(self.userns_grant, &self.attachment)
324    }
325}
326
327/// The readings line every namespace refusal ends with — `E-CAP-005`'s, and `E-CAP-003`'s for
328/// `sealed`. AppArmor's restriction knob reads `1` for both grants decided with it on, and `0` for
329/// the one decided with it off.
330pub(crate) fn host_readings(
331    userns_grant: crate::sealed::UsernsGrant,
332    attachment: &crate::sealed::ProfileAttachment,
333) -> String {
334    use crate::sealed::{ProfileAttachment, UsernsGrant};
335    let restriction = match userns_grant {
336        UsernsGrant::ApparmorAbsent => "AppArmor not enabled",
337        UsernsGrant::RestrictionDisabledHostWide => {
338            "kernel.apparmor_restrict_unprivileged_userns=0"
339        }
340        UsernsGrant::ProfileConfining | UsernsGrant::Withheld => {
341            "kernel.apparmor_restrict_unprivileged_userns=1"
342        }
343    };
344    let profile = match attachment {
345        ProfileAttachment::Sealed { profile } | ProfileAttachment::Other { profile } => {
346            profile.as_str()
347        }
348        ProfileAttachment::Unconfined => "unconfined",
349        ProfileAttachment::NotReported => "not reported",
350    };
351    format!("Host readings: {restriction}; AppArmor profile confining mur: {profile}.")
352}
353
354/// What a step after `unshare` failing says about the namespace, ahead of the cause.
355fn step_context(step: CreationStep) -> Option<&'static str> {
356    match step {
357        CreationStep::Unshare => None,
358        CreationStep::IdentityMaps => Some(
359            "The namespace was created and could not be owned: writing its identity map was \
360             refused.",
361        ),
362        CreationStep::Loopback | CreationStep::LocalRoute => Some(
363            "The namespace was created and owned, and configuring it was refused — the mark of a \
364             confinement that withholds CAP_NET_ADMIN inside the namespace.",
365        ),
366        CreationStep::PidNamespace => Some(
367            "The user and network namespaces were created without CLONE_NEWPID, and creating a \
368             PID namespace with them was refused — the mark of a seccomp profile or confinement \
369             that permits unshare but not CLONE_NEWPID.",
370        ),
371        CreationStep::Holder => Some(
372            "The namespaces were created, and forking the PID namespace's first process failed.",
373        ),
374        CreationStep::TcpListener
375        | CreationStep::Resolver
376        | CreationStep::NamespaceHandles
377        | CreationStep::HandOff => {
378            Some("The namespace was created and configured, and handing it to the runtime failed.")
379        }
380    }
381}
382
383/// The likely cause of a refused namespace on a kernel that provides one, chosen from the
384/// readings.
385///
386/// AppArmor absent comes first because `/proc/self/attr/current` then reports another security
387/// module's label, which is no AppArmor profile. A process another profile confines is next: the
388/// host's restriction knob does not govern it, whatever it reads.
389fn grant_cause(
390    userns_grant: crate::sealed::UsernsGrant,
391    attachment: &crate::sealed::ProfileAttachment,
392) -> String {
393    use crate::sealed::{
394        ProfileAttachment, UsernsGrant, SEALED_APPARMOR_PROFILE_NAME, SEALED_APPARMOR_PROFILE_PATH,
395    };
396    const CONTAINER_REMEDY: &str = "Inside a container, the container runtime's seccomp \
397         profile is the usual blocker: it refuses unshare(CLONE_NEWUSER) and setns whatever \
398         capabilities the container holds. Run the container with a seccomp profile that permits \
399         them (Docker: `--security-opt seccomp=<file>`; Kubernetes: `seccompProfile: \
400         Localhost`), or create the network namespace outside the container and run mur inside \
401         it.";
402    match (userns_grant, attachment) {
403        (UsernsGrant::ApparmorAbsent, _) => format!(
404            "Likely cause: not AppArmor, which is not enabled on this host — a container \
405             runtime's seccomp profile, or another security module's policy. \
406             {CONTAINER_REMEDY} On a bare host, check the policy of the security module in use."
407        ),
408        (_, ProfileAttachment::Other { profile }) => format!(
409            "Likely cause: the AppArmor profile `{profile}` confines mur. \
410             kernel.apparmor_restrict_unprivileged_userns does not govern a process another \
411             profile confines: that profile, and a container runtime's seccomp profile, decide \
412             whether it may create a user namespace. Run the container with an AppArmor profile \
413             and a seccomp profile that permit unshare(CLONE_NEWUSER) and setns (Docker: \
414             `--security-opt apparmor=<profile> --security-opt seccomp=<file>`; Kubernetes: \
415             `appArmorProfile` and `seccompProfile: Localhost`), or create the network namespace \
416             outside the container and run mur inside it."
417        ),
418        (UsernsGrant::ProfileConfining, _) | (_, ProfileAttachment::Sealed { .. }) => format!(
419            "Likely cause: the {SEALED_APPARMOR_PROFILE_NAME} AppArmor profile confines mur and \
420             the creation still failed, so the profile the kernel has loaded may be stale. Reload \
421             it with `sudo apparmor_parser -r {SEALED_APPARMOR_PROFILE_PATH}` (for a checkout \
422             build, re-run `scripts/install-dev-apparmor.sh`), and check `mur doctor`'s \
423             comparison of the installed profile with the one this build ships."
424        ),
425        (UsernsGrant::RestrictionDisabledHostWide, _) => format!(
426            "Likely cause: not AppArmor, whose unprivileged-userns restriction is off on this host \
427             — a container runtime's seccomp profile, or another security module's policy. \
428             {CONTAINER_REMEDY}"
429        ),
430        (UsernsGrant::Withheld, _) => format!(
431            "Likely cause: AppArmor's unprivileged-userns restriction \
432             (kernel.apparmor_restrict_unprivileged_userns=1) applies to this binary, which no \
433             {SEALED_APPARMOR_PROFILE_NAME} profile confines. Install and load the profile shipped \
434             with mur, `sudo install -m 644 packaging/apparmor/{SEALED_APPARMOR_PROFILE_NAME} \
435             {SEALED_APPARMOR_PROFILE_PATH} && sudo apparmor_parser -r \
436             {SEALED_APPARMOR_PROFILE_PATH}` (or re-run the mur installer as root), then re-run. \
437             Building out of a checkout, where the binary sits at ./target/{{debug,release}}/mur \
438             and no shipped profile attaches to it: run `scripts/install-dev-apparmor.sh`, which \
439             generates and loads the same grant for those two paths. LAST RESORT, only where a \
440             profile genuinely cannot be loaded: `sudo sysctl -w \
441             kernel.apparmor_restrict_unprivileged_userns=0` — this removes unprivileged-userns \
442             hardening from every program on the machine, not just from mur, and is not the \
443             configuration murmur ships."
444        ),
445    }
446}
447
448/// The likely cause of an `unshare` the kernel could not honour, by the step and its `errno`.
449fn kernel_cause(step: Option<CreationStep>, errno: Option<i32>) -> &'static str {
450    if step == Some(CreationStep::PidNamespace) {
451        return match errno {
452            Some(libc::ENOSPC) => {
453                "Likely cause: the kernel's limit on PID namespaces — user.max_pid_namespaces is \
454                 0, or every namespace it allows is in use. Raise it where it is set \
455                 (`sudo sysctl -w user.max_pid_namespaces=10000`), then re-run."
456            }
457            _ => {
458                "Likely cause: this kernel does not provide PID namespaces (built with \
459                 CONFIG_PID_NS=n). Run mur on a kernel built with CONFIG_PID_NS=y."
460            }
461        };
462    }
463    match errno {
464        Some(libc::ENOSPC) => {
465            "Likely cause: the kernel's limit on user namespaces — user.max_user_namespaces is 0, \
466             or every namespace it allows is in use. Raise it where it is set, on the host or in \
467             the user namespace mur runs in (`sudo sysctl -w user.max_user_namespaces=10000`), \
468             then re-run."
469        }
470        Some(libc::EUSERS) => {
471            "Likely cause: the kernel's nesting limit for user namespaces — mur already runs \
472             inside as many nested user namespaces as the kernel allows. Run it fewer levels \
473             deep."
474        }
475        _ => {
476            "Likely cause: this kernel does not provide user namespaces (built with \
477             CONFIG_USER_NS=n). Run mur on a kernel built with CONFIG_USER_NS=y."
478        }
479    }
480}
481
482/// The symbolic name of the `errno` values a namespace creation can leave.
483pub(crate) fn errno_name(errno: i32) -> Option<&'static str> {
484    Some(match errno {
485        libc::EPERM => "EPERM",
486        libc::ENOENT => "ENOENT",
487        libc::EACCES => "EACCES",
488        libc::EINVAL => "EINVAL",
489        libc::ENOSPC => "ENOSPC",
490        libc::ENOSYS => "ENOSYS",
491        libc::EUSERS => "EUSERS",
492        libc::ENOMEM => "ENOMEM",
493        libc::EAGAIN => "EAGAIN",
494        libc::EADDRINUSE => "EADDRINUSE",
495        libc::EMFILE => "EMFILE",
496        libc::ENFILE => "ENFILE",
497        libc::EROFS => "EROFS",
498        libc::ENOTDIR => "ENOTDIR",
499        libc::EBUSY => "EBUSY",
500        _ => return None,
501    })
502}
503
504/// [`errno_name`], or `errno <n>` for a value outside it.
505pub(crate) fn errno_label(errno: i32) -> String {
506    errno_name(errno).map_or_else(|| format!("errno {errno}"), str::to_string)
507}
508
509/// The C library's description of `errno`, without the `(os error <n>)` suffix `std` appends.
510pub(crate) fn errno_words(errno: i32) -> String {
511    let rendered = std::io::Error::from_raw_os_error(errno).to_string();
512    rendered
513        .strip_suffix(&format!(" (os error {errno})"))
514        .map_or_else(|| rendered.clone(), str::to_string)
515}
516
517/// Whether a namespace attempt is an `E-CAP-005` failure, and which. Pure, and unit-testable on
518/// any OS.
519///
520/// The attempt alone decides: `None` exactly when it succeeded. The readings only choose the
521/// words of the refusal.
522pub(crate) fn egress_namespace_failure(
523    is_linux: bool,
524    attempt: Result<(), NamespaceAttemptError>,
525    userns_grant: crate::sealed::UsernsGrant,
526    attachment: crate::sealed::ProfileAttachment,
527) -> Option<EgressNamespaceFailure> {
528    // Not Linux: there is no network namespace here. A non-Linux host resolves to
529    // `EnforcementTier::EnvironmentOnly`, installs no kernel subprocess sandbox at all, and says
530    // so loudly (`W_SEC_001`). Refusing here would turn every macOS run into an error about a
531    // mechanism macOS has never had.
532    if !is_linux {
533        return None;
534    }
535    attempt
536        .err()
537        .map(|error| failure_of(error, userns_grant, attachment))
538}
539
540/// The failure a failed attempt is, classified by its step and `errno`.
541fn failure_of(
542    error: NamespaceAttemptError,
543    userns_grant: crate::sealed::UsernsGrant,
544    attachment: crate::sealed::ProfileAttachment,
545) -> EgressNamespaceFailure {
546    let (blocker, failed_call, errno) = match error {
547        NamespaceAttemptError::Step { step, errno } => {
548            let blocker = match (step, errno) {
549                (
550                    CreationStep::Unshare,
551                    libc::ENOSPC | libc::EINVAL | libc::ENOSYS | libc::EUSERS,
552                )
553                | (CreationStep::PidNamespace, libc::ENOSPC | libc::EINVAL) => {
554                    EgressNamespaceBlocker::KernelSupportMissing
555                }
556                _ => EgressNamespaceBlocker::CapabilityGrantMissing,
557            };
558            (blocker, step.describe().to_string(), Some(errno))
559        }
560        NamespaceAttemptError::Creator(detail) => {
561            (EgressNamespaceBlocker::CreatorUnavailable, detail, None)
562        }
563    };
564    EgressNamespaceFailure {
565        blocker,
566        failed_call,
567        errno,
568        userns_grant,
569        attachment,
570    }
571}
572
573/// Whether this host can give a capsule its own network namespace, answered by creating one.
574///
575/// A fresh [`crate::sandbox::HostProbe`] each call, so it re-reads the host: the attempt is the
576/// launch's own construction ([`linux::create_session_netns`] with an empty allowlist, the
577/// namespace it hands over dropped at once), and it cannot pass where a launch would fail, or fail
578/// where a launch would pass. A session does not call this: it reads the failure off its own
579/// `HostProbe`, so staging and launch judge one reading.
580#[must_use]
581pub fn detect_egress_namespace_failure() -> Option<EgressNamespaceFailure> {
582    crate::sandbox::HostProbe::probe().egress_namespace_failure()
583}
584
585/// Whether a test that needs a capsule network namespace must stand down on this host, printing
586/// the failure when it must.
587///
588/// Test support, not a runtime code path. Asked through [`detect_egress_namespace_failure`], the
589/// same probe `stage_session` consults, so the gate and the staging refusal cannot reach different
590/// conclusions about the host.
591///
592/// Narrower than [`crate::skip_without_host_support`], which also requires a delegated cgroup
593/// scope: a test driving `execute_shell` directly needs the namespace and nothing else, and
594/// standing down on the cgroup half would skip it on hosts where it runs perfectly well.
595pub fn skip_without_egress_namespace(test_name: &str) -> bool {
596    match detect_egress_namespace_failure() {
597        Some(failure) => {
598            crate::runtime_err!("[SKIP-HOST] {test_name}: {}", failure.reason());
599            true
600        }
601        None => false,
602    }
603}
604
605/// The host pid of the process a session's command knows as `namespace_pid` (its `$$` or `$!`),
606/// or `None` when no live process matches.
607///
608/// Test support, not a runtime code path. Commands run in their session's PID namespace, so a pid
609/// a command prints is a namespace pid. `runtime_pid` is the process that runs the session — the
610/// test process itself for an in-process session, the `mur` child for a launched one — which holds
611/// a descriptor of each of its sessions' PID namespaces (`pid:[<inode>]`). The match is the
612/// process in one of those namespaces whose `NSpid:` line ends in `namespace_pid`, so two
613/// sessions on one host, which number their processes alike, are never confused.
614#[cfg(target_os = "linux")]
615#[must_use]
616pub fn host_pid_in_session(runtime_pid: u32, namespace_pid: i32) -> Option<i32> {
617    host_pids_in_sessions(runtime_pid, namespace_pid)
618        .into_iter()
619        .next()
620}
621
622/// [`host_pid_in_session`] off Linux, where commands run in no PID namespace: the pid a command
623/// prints is already its host pid, so it is returned unchanged. `None` for a pid no process can
624/// have.
625#[cfg(not(target_os = "linux"))]
626#[must_use]
627pub fn host_pid_in_session(_runtime_pid: u32, namespace_pid: i32) -> Option<i32> {
628    (namespace_pid > 0).then_some(namespace_pid)
629}
630
631/// Every host pid [`host_pid_in_session`] could answer: one per session of `runtime_pid` that has
632/// a live process numbered `namespace_pid`, for a test that holds several sessions at once.
633#[cfg(target_os = "linux")]
634#[must_use]
635pub fn host_pids_in_sessions(runtime_pid: u32, namespace_pid: i32) -> Vec<i32> {
636    let Ok(fds) = std::fs::read_dir(format!("/proc/{runtime_pid}/fd")) else {
637        return Vec::new();
638    };
639    let held: Vec<std::path::PathBuf> = fds
640        .filter_map(|entry| std::fs::read_link(entry.ok()?.path()).ok())
641        .filter(|target| target.to_string_lossy().starts_with("pid:["))
642        .collect();
643    if held.is_empty() {
644        return Vec::new();
645    }
646    let Ok(entries) = std::fs::read_dir("/proc") else {
647        return Vec::new();
648    };
649    entries
650        .filter_map(Result::ok)
651        .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
652        .filter(|pid| {
653            let in_session = std::fs::read_link(format!("/proc/{pid}/ns/pid"))
654                .is_ok_and(|namespace| held.contains(&namespace));
655            in_session
656                && std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(|status| {
657                    status
658                        .lines()
659                        .find_map(|line| line.strip_prefix("NSpid:"))
660                        .and_then(|fields| fields.split_whitespace().last())
661                        .and_then(|last| last.parse::<i32>().ok())
662                        == Some(namespace_pid)
663                })
664        })
665        .collect()
666}
667
668/// The host pid of the live process whose working directory is `workdir` and whose pid inside
669/// its PID namespace is `namespace_pid`, for a session run by a `mur` this process did not start
670/// in-process.
671///
672/// Test support, not a runtime code path. [`host_pid_in_session`] reads the namespace handles
673/// the runtime holds, which a separate `mur` process does not let another process read (it is
674/// non-dumpable); a command's process is dumpable once it has exec'd, so its working directory
675/// and its `NSpid:` line are readable. A test's workdir is its own temporary directory, so the
676/// working directory tells its session's processes from every other session's. `None` when no
677/// such process is live.
678#[cfg(target_os = "linux")]
679#[must_use]
680pub fn host_pid_in_workdir(workdir: &std::path::Path, namespace_pid: i32) -> Option<i32> {
681    let workdir = std::fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf());
682    std::fs::read_dir("/proc")
683        .ok()?
684        .filter_map(Result::ok)
685        .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
686        .find(|pid| {
687            std::fs::read_link(format!("/proc/{pid}/cwd")).is_ok_and(|cwd| cwd == workdir)
688                && std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(|status| {
689                    let fields: Vec<&str> = status
690                        .lines()
691                        .find_map(|line| line.strip_prefix("NSpid:"))
692                        .map(|fields| fields.split_whitespace().collect())
693                        .unwrap_or_default();
694                    fields.len() >= 2
695                        && fields.last().and_then(|last| last.parse::<i32>().ok())
696                            == Some(namespace_pid)
697                })
698        })
699}
700
701/// [`host_pid_in_workdir`] off Linux, where commands run in no PID namespace: the pid a command
702/// prints is already its host pid, so it is returned unchanged. `None` for a pid no process can
703/// have.
704#[cfg(not(target_os = "linux"))]
705#[must_use]
706pub fn host_pid_in_workdir(_workdir: &std::path::Path, namespace_pid: i32) -> Option<i32> {
707    (namespace_pid > 0).then_some(namespace_pid)
708}
709
710/// Refuses the launch when a capsule that can spawn subprocesses is on a host that cannot
711/// give them a network namespace.
712///
713/// Pure — the host answer is passed in — so the decision is testable on any OS at any tier.
714///
715/// `can_spawn_subprocess` gates the whole check rather than
716/// `capabilities.network.allow` being non-empty, and the asymmetry is deliberate: an empty
717/// allowlist means *no* egress, and without the namespace to deliver that, the capsule would get
718/// unrestricted egress instead. Refusing only capsules that asked for some network would let
719/// exactly the capsules that asked for none run with the most.
720pub fn check_egress_namespace(
721    can_spawn_subprocess: bool,
722    failure: Option<&EgressNamespaceFailure>,
723) -> Result<(), crate::errors::RuntimeError> {
724    match failure {
725        Some(failure) if can_spawn_subprocess => Err(unavailable(failure)),
726        _ => Ok(()),
727    }
728}
729
730/// The `E-CAP-005` refusal of `failure`.
731fn unavailable(failure: &EgressNamespaceFailure) -> crate::errors::RuntimeError {
732    crate::errors::RuntimeError::EgressNamespaceUnavailable {
733        blocker: failure.blocker,
734        reason: failure.reason(),
735        required_by: Vec::new(),
736    }
737}
738
739/// What [`SessionNetns::establish`] reports when the session's namespace cannot be created.
740///
741/// A failed step is the `E-CAP-005` refusal staging would have rendered for it, with the readings
742/// passed in. A creator that could not run is the runtime's own account: that spawn fails, and
743/// the next one tries again.
744fn session_netns_creation_error(
745    error: NamespaceAttemptError,
746    userns_grant: crate::sealed::UsernsGrant,
747    attachment: crate::sealed::ProfileAttachment,
748) -> String {
749    match error {
750        NamespaceAttemptError::Creator(detail) => {
751            format!("sandbox: could not create this session's network namespace: {detail}")
752        }
753        step => format!(
754            "sandbox: E-CAP-005: {}",
755            unavailable(&failure_of(step, userns_grant, attachment))
756        ),
757    }
758}
759
760// ---------------------------------------------------------------- the plan
761
762/// The fully-resolved, allocation-free description of the namespace the forked child will build.
763///
764/// Precomputed in the **parent**, before `fork()`, for the reason [`crate::sealed`] documents at
765/// length: the `pre_exec` window permits only async-signal-safe work, and allocating there can
766/// deadlock on an allocator lock a different thread of the parent held at `fork()` time. The
767/// child reads this and issues syscalls; it computes nothing and allocates nothing.
768#[derive(Debug, Clone)]
769pub(crate) struct CapsuleNetnsPlan {
770    /// TCP ports to bind and listen on inside the namespace, ascending — exactly
771    /// `egress_proxy::egress_listen_ports` for this capsule's allowlist.
772    ///
773    /// May be empty, which is a capsule with no TCP egress at all. The UDP :53 socket is bound
774    /// regardless, so even that capsule gets `REFUSED` *answers* to its lookups rather than the
775    /// multi-second resolver stall a dropped packet produces.
776    pub(crate) tcp_ports: Vec<u16>,
777    /// The real uid/gid, read in the parent.
778    ///
779    /// Load-bearing ordering, exactly as in `namespace_probe`'s rehearsals: inside a fresh user
780    /// namespace with no map written yet, `getuid()` reports the overflow id (65534), and writing
781    /// that into `uid_map` is refused on every host.
782    pub(crate) uid: u32,
783    pub(crate) gid: u32,
784    /// [`FORCE_PID_NAMESPACE_FAILURE`] as armed on the thread that resolved this plan: the
785    /// creator then reports [`CreationStep::PidNamespace`] with `EPERM`.
786    #[cfg(test)]
787    pub(crate) force_pid_namespace_failure: bool,
788}
789
790#[cfg(test)]
791thread_local! {
792    /// Test seam: makes the creator refuse the PID namespace as a host whose seccomp profile
793    /// denies `CLONE_NEWPID` would, after creating the user and network namespaces.
794    pub(crate) static FORCE_PID_NAMESPACE_FAILURE: std::cell::Cell<bool> =
795        const { std::cell::Cell::new(false) };
796}
797
798/// RAII arm for [`FORCE_PID_NAMESPACE_FAILURE`].
799#[cfg(test)]
800pub(crate) struct ForcePidNamespaceFailure;
801
802#[cfg(test)]
803impl ForcePidNamespaceFailure {
804    pub(crate) fn arm() -> Self {
805        FORCE_PID_NAMESPACE_FAILURE.with(|flag| flag.set(true));
806        Self
807    }
808}
809
810#[cfg(test)]
811impl Drop for ForcePidNamespaceFailure {
812    fn drop(&mut self) {
813        FORCE_PID_NAMESPACE_FAILURE.with(|flag| flag.set(false));
814    }
815}
816
817impl CapsuleNetnsPlan {
818    /// Resolves the plan for one session's namespace.
819    #[cfg(target_os = "linux")]
820    #[allow(unsafe_code)]
821    pub(crate) fn resolve(tcp_ports: Vec<u16>) -> Self {
822        // SAFETY: `getuid`/`getgid` take no arguments, dereference nothing and cannot fail.
823        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
824        Self {
825            tcp_ports,
826            uid,
827            gid,
828            #[cfg(test)]
829            force_pid_namespace_failure: FORCE_PID_NAMESPACE_FAILURE.with(std::cell::Cell::get),
830        }
831    }
832
833    /// How many descriptors the child will send: one per TCP port, plus the resolver socket.
834    pub(crate) fn socket_count(&self) -> usize {
835        self.tcp_ports.len() + 1
836    }
837}
838
839// ---------------------------------------------------------------- the session's namespace
840
841/// One session's network namespace, created on first use and shared by every command after it.
842///
843/// Always held as `Arc<SessionNetns>` on [`crate::sandbox::ShellEnforcement`], so every clone of a
844/// session's enforcement — each tool call's, each demoted command's, a plan's — reaches the same
845/// namespace. Its state moves one way: not yet created, established, ended. An ended namespace is
846/// never recreated, so a command spawned after its session ended is refused rather than given a
847/// fresh namespace nobody will tear down.
848///
849/// Off Linux there is no namespace to create: the type exists so the session-end path needs no
850/// `cfg`, and it only ever moves from not yet created to ended.
851#[derive(Debug, Default)]
852pub(crate) struct SessionNetns {
853    state: std::sync::Mutex<SessionNetnsState>,
854    /// Successful creations, which a test compares against the commands it ran.
855    #[cfg(test)]
856    creations: std::sync::atomic::AtomicUsize,
857}
858
859#[derive(Debug, Default)]
860enum SessionNetnsState {
861    #[default]
862    NotYetCreated,
863    #[cfg(target_os = "linux")]
864    Established(linux::EstablishedNetns),
865    Ended,
866}
867
868/// What a command's `pre_exec` window needs to join its session's namespaces: the user, network
869/// and PID namespace descriptors.
870///
871/// Shares the session's descriptors rather than duplicating them, so they stay open for as long as
872/// the `Command` holding this does, even if the session ends between `establish` and the spawn: a
873/// join then enters a namespace whose processes are being killed, never a reused descriptor number.
874#[cfg(target_os = "linux")]
875#[derive(Debug, Clone)]
876pub(crate) struct SessionNetnsJoin {
877    user_ns: std::sync::Arc<std::os::fd::OwnedFd>,
878    net_ns: std::sync::Arc<std::os::fd::OwnedFd>,
879    pid_ns: std::sync::Arc<std::os::fd::OwnedFd>,
880}
881
882#[cfg(target_os = "linux")]
883impl SessionNetnsJoin {
884    /// `(user_ns, net_ns)`, for [`join_session_netns`].
885    pub(crate) fn raw_fds(&self) -> (std::os::fd::RawFd, std::os::fd::RawFd) {
886        use std::os::fd::AsRawFd;
887        (self.user_ns.as_raw_fd(), self.net_ns.as_raw_fd())
888    }
889
890    /// The session's PID namespace (`/proc/<creator>/ns/pid_for_children`), which a command's
891    /// relay enters with `setns(2)` before forking the command into it.
892    pub(crate) fn pid_ns_fd(&self) -> std::os::fd::RawFd {
893        use std::os::fd::AsRawFd;
894        self.pid_ns.as_raw_fd()
895    }
896}
897
898impl SessionNetns {
899    /// A session whose namespace does not exist yet. Creates nothing.
900    pub(crate) fn new() -> Self {
901        Self::default()
902    }
903
904    /// The handles a command joins the session's namespace through, creating the namespace and
905    /// starting the session's egress proxy on the first call.
906    ///
907    /// A creation step that fails is refused with `E-CAP-005` in the words staging uses, so a
908    /// first command, the first time anything asks, is refused the way a launch would have
909    /// been.
910    ///
911    /// Serialised on one mutex, so concurrent first commands create exactly one namespace. A
912    /// failed creation leaves the namespace not yet created and leaks nothing, so that command's
913    /// spawn fails and the next one tries again. `tcp_ports` and `policy` are read on the call
914    /// that creates the namespace; every later call is handed the namespace that call created.
915    #[cfg(target_os = "linux")]
916    pub(crate) fn establish(
917        &self,
918        tcp_ports: &[u16],
919        policy: crate::egress_proxy::EgressPolicy,
920    ) -> Result<SessionNetnsJoin, String> {
921        let mut state = self
922            .state
923            .lock()
924            .unwrap_or_else(std::sync::PoisonError::into_inner);
925        match &*state {
926            SessionNetnsState::Established(established) => return Ok(established.join()),
927            SessionNetnsState::Ended => {
928                return Err(
929                    "sandbox: this session has ended, and its network namespace was released \
930                     with it; no further command runs in it"
931                        .to_string(),
932                )
933            }
934            SessionNetnsState::NotYetCreated => {}
935        }
936        let plan = CapsuleNetnsPlan::resolve(tcp_ports.to_vec());
937        // The readings are taken here, in the runtime, when a creation has failed: the creator
938        // child exits as soon as it reports.
939        let created = linux::create_session_netns(&plan).map_err(|error| {
940            session_netns_creation_error(
941                error,
942                crate::sealed::userns_grant(),
943                crate::sealed::inspect_profile_attachment(),
944            )
945        })?;
946        let established = created.serve(policy).map_err(|detail| {
947            format!("sandbox: could not create this session's network namespace: {detail}")
948        })?;
949        let join = established.join();
950        *state = SessionNetnsState::Established(established);
951        #[cfg(test)]
952        self.creations
953            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
954        Ok(join)
955    }
956
957    /// Releases the namespace: stops the session's egress proxy, closes the namespace descriptors
958    /// and closes the holder's lifeline. Idempotent, and final — [`Self::establish`] refuses every
959    /// call after it.
960    ///
961    /// The holder, pid 1 of the session's PID namespace, exits when its lifeline closes, and the
962    /// kernel then kills every process left in that namespace: a command a cgroup scope did not
963    /// catch, or every command on a host without one. The caller kills the scope first
964    /// (`ShellEnforcement::end_session`).
965    pub(crate) fn end(&self) {
966        let previous = std::mem::replace(
967            &mut *self
968                .state
969                .lock()
970                .unwrap_or_else(std::sync::PoisonError::into_inner),
971            SessionNetnsState::Ended,
972        );
973        #[cfg(target_os = "linux")]
974        if let SessionNetnsState::Established(established) = previous {
975            established.release();
976        }
977        #[cfg(not(target_os = "linux"))]
978        drop(previous);
979    }
980
981    /// How many namespaces this session has created: `0` before its first command, `1` after.
982    #[cfg(test)]
983    pub(crate) fn creations(&self) -> usize {
984        self.creations.load(std::sync::atomic::Ordering::SeqCst)
985    }
986}
987
988impl Drop for SessionNetns {
989    /// The backstop for a session that never reached `end_session`: the proxy stops and the
990    /// descriptors close when the last enforcement holding this goes.
991    fn drop(&mut self) {
992        self.end();
993    }
994}
995
996/// Bytes in a forked child's step report: a `u32` step code, then the `errno` as an `i32`, both
997/// native-endian. The namespace creator and the namespace probe's rehearsals both report this way.
998pub(crate) const STEP_RECORD_LEN: usize = 8;
999
1000/// A child's step report. Integers only, so a forked child builds it without allocating.
1001pub(crate) fn encode_step_record(code: u32, errno: i32) -> [u8; STEP_RECORD_LEN] {
1002    let mut record = [0u8; STEP_RECORD_LEN];
1003    record[..4].copy_from_slice(&code.to_ne_bytes());
1004    record[4..].copy_from_slice(&errno.to_ne_bytes());
1005    record
1006}
1007
1008/// `(code, errno)` from a child's step report.
1009pub(crate) fn decode_step_record(record: &[u8; STEP_RECORD_LEN]) -> (u32, i32) {
1010    (
1011        u32::from_ne_bytes([record[0], record[1], record[2], record[3]]),
1012        i32::from_ne_bytes([record[4], record[5], record[6], record[7]]),
1013    )
1014}
1015
1016#[cfg(target_os = "linux")]
1017pub(crate) use linux::{create_session_netns, join_session_netns, reap_child, send_fds};
1018
1019#[cfg(target_os = "linux")]
1020#[allow(unsafe_code)]
1021mod linux {
1022    use std::io;
1023    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
1024    use std::sync::Arc;
1025
1026    use super::{
1027        decode_step_record, encode_step_record, CapsuleNetnsPlan, CreationStep,
1028        NamespaceAttemptError, SessionNetnsJoin, MAX_NAMESPACE_SOCKETS, MAX_SESSION_NETNS_FDS,
1029        NAMESPACE_HANDLE_COUNT, STEP_RECORD_LEN,
1030    };
1031
1032    // ------------------------------------------------------------ netlink constants
1033    //
1034    // Spelled out here rather than taken from `libc`, whose rtnetlink coverage varies by target
1035    // and libc flavour. These are uapi ABI constants — fixed for the lifetime of the interface —
1036    // and a wrong one fails loudly at the netlink ACK rather than silently.
1037
1038    const RTM_NEWROUTE: u16 = 24;
1039    const NLM_F_REQUEST: u16 = 0x001;
1040    const NLM_F_ACK: u16 = 0x004;
1041    const NLM_F_EXCL: u16 = 0x200;
1042    const NLM_F_CREATE: u16 = 0x400;
1043    const NLMSG_ERROR: u16 = 2;
1044    /// Table 255, `local` — the table whose routes cause *input* delivery. A default route here
1045    /// is what makes every address locally deliverable.
1046    const RT_TABLE_LOCAL: u8 = 255;
1047    const RTPROT_BOOT: u8 = 3;
1048    const RT_SCOPE_HOST: u8 = 254;
1049    const RTN_LOCAL: u8 = 2;
1050    const RTA_OIF: u16 = 4;
1051    /// `lo` is always interface index 1 in a freshly created network namespace: it is the only
1052    /// interface there, and the kernel creates it first.
1053    const LOOPBACK_IFINDEX: u32 = 1;
1054
1055    const NLMSGHDR_LEN: usize = 16;
1056    const RTMSG_LEN: usize = 12;
1057    const RTATTR_OIF_LEN: usize = 8;
1058    const ROUTE_REQUEST_LEN: usize = NLMSGHDR_LEN + RTMSG_LEN + RTATTR_OIF_LEN;
1059
1060    // ------------------------------------------------------------ the creator child
1061
1062    /// A failed step and the `errno` it left, carried out of the creator as a fixed-size record.
1063    ///
1064    /// `Copy` and built from integers only, so the creator reports a failure without allocating.
1065    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1066    pub(super) struct CreationFailure {
1067        step: CreationStep,
1068        errno: i32,
1069    }
1070
1071    impl CreationFailure {
1072        /// The failure of `step`, with the `errno` the failing call just left.
1073        ///
1074        /// # Safety
1075        /// Reads the calling thread's `errno`; async-signal-safe.
1076        unsafe fn at(step: CreationStep) -> Self {
1077            // SAFETY: `__errno_location` returns the calling thread's own errno slot, which is
1078            // always valid to read.
1079            let errno = unsafe { *libc::__errno_location() };
1080            Self { step, errno }
1081        }
1082
1083        fn encode(self) -> [u8; STEP_RECORD_LEN] {
1084            encode_step_record(self.step as u32, self.errno)
1085        }
1086
1087        fn decode(record: &[u8; STEP_RECORD_LEN]) -> Option<Self> {
1088            let (code, errno) = decode_step_record(record);
1089            let step = CreationStep::from_code(code)?;
1090            Some(Self { step, errno })
1091        }
1092    }
1093
1094    impl From<CreationFailure> for NamespaceAttemptError {
1095        fn from(failure: CreationFailure) -> Self {
1096            NamespaceAttemptError::Step {
1097                step: failure.step,
1098                errno: failure.errno,
1099            }
1100        }
1101    }
1102
1103    /// Builds the session's network and PID namespaces and hands them to the parent: the network
1104    /// namespace's listening sockets, then descriptors for its user, network and PID namespaces,
1105    /// in one `SCM_RIGHTS` message ordered `[tcp listeners…, udp53, user_ns, net_ns, pid_ns]`.
1106    ///
1107    /// One `unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID)` creates all three, so the PID
1108    /// namespace is owned by the session's user namespace. When that call fails, a second
1109    /// `unshare` without `CLONE_NEWPID` tells the two refusals apart: if it succeeds, the failure
1110    /// is [`CreationStep::PidNamespace`] with the first call's `errno`, and the namespaces it made
1111    /// die with this child. Either way there is no namespace.
1112    ///
1113    /// After the identity maps the creator forks the holder ([`run_holder`]), which becomes pid 1
1114    /// of the PID namespace and keeps only `lifeline_fd`, the read end of the pipe whose write end
1115    /// the runtime holds.
1116    ///
1117    /// Runs only in the creator child [`create_session_netns`] forks, which runs no capsule code,
1118    /// installs no seccomp filter or Landlock domain and joins no cgroup scope; it `_exit`s as
1119    /// soon as this returns. Commands join what this builds through [`join_session_netns`]. A
1120    /// `sealed` command goes on to `unshare(CLONE_NEWUSER | CLONE_NEWNS)` a *nested* user
1121    /// namespace, and a process in a descendant user namespace holds no capabilities in the
1122    /// ancestor that owns this network namespace, so a `sealed` capsule cannot reconfigure the
1123    /// namespace confining it: `SIOCSIFFLAGS` returns `EPERM` from inside the nested namespace.
1124    ///
1125    /// # Safety
1126    /// Post-`fork()` child context only. Every call below is async-signal-safe and nothing here
1127    /// allocates: `plan` was fully resolved in the parent, and a failure is a `Copy` record.
1128    unsafe fn create_capsule_netns(
1129        plan: &CapsuleNetnsPlan,
1130        sock_fd: RawFd,
1131        lifeline_fd: RawFd,
1132        lifeline_write_fd: RawFd,
1133    ) -> Result<(), CreationFailure> {
1134        // SAFETY: forked-child context; the caller's `unsafe` contract covers this whole body.
1135        unsafe {
1136            #[cfg(test)]
1137            let forced = plan.force_pid_namespace_failure;
1138            #[cfg(not(test))]
1139            let forced = false;
1140            let pid_namespace_refused = if forced {
1141                Some(libc::EPERM)
1142            } else if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET | libc::CLONE_NEWPID)
1143                == 0
1144            {
1145                None
1146            } else {
1147                Some(*libc::__errno_location())
1148            };
1149            if let Some(errno) = pid_namespace_refused {
1150                if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
1151                    return Err(CreationFailure::at(CreationStep::Unshare));
1152                }
1153                return Err(CreationFailure {
1154                    step: CreationStep::PidNamespace,
1155                    errno,
1156                });
1157            }
1158            if write_identity_maps(plan.uid, plan.gid).is_err() {
1159                return Err(CreationFailure::at(CreationStep::IdentityMaps));
1160            }
1161            // The holder: the first process forked after `CLONE_NEWPID`, so pid 1 of the
1162            // session's PID namespace. Forked before any socket exists, so it inherits the
1163            // fewest descriptors, every one of which it closes. The lifeline's write end is
1164            // closed first: the holder must never hold its own lifeline, whatever its descriptor
1165            // hygiene manages.
1166            libc::close(lifeline_write_fd);
1167            let holder = libc::fork();
1168            if holder < 0 {
1169                return Err(CreationFailure::at(CreationStep::Holder));
1170            }
1171            if holder == 0 {
1172                run_holder(lifeline_fd);
1173            }
1174            if bring_loopback_up().is_err() {
1175                return Err(CreationFailure::at(CreationStep::Loopback));
1176            }
1177            if add_local_default_route().is_err() {
1178                return Err(CreationFailure::at(CreationStep::LocalRoute));
1179            }
1180
1181            // One TCP listener per allowlisted port, then the resolver socket — the order the
1182            // parent's `start_egress_proxy` splits them back apart in — then the three namespace
1183            // handles, which the parent takes off the end.
1184            let mut fds = [-1 as RawFd; MAX_SESSION_NETNS_FDS];
1185            let mut count = 0usize;
1186
1187            for port in &plan.tcp_ports {
1188                let fd = bind_tcp_listener(*port);
1189                if fd < 0 {
1190                    let failure = CreationFailure::at(CreationStep::TcpListener);
1191                    close_all(&fds[..count]);
1192                    return Err(failure);
1193                }
1194                fds[count] = fd;
1195                count += 1;
1196            }
1197
1198            let resolver = bind_dns_socket();
1199            if resolver < 0 {
1200                let failure = CreationFailure::at(CreationStep::Resolver);
1201                close_all(&fds[..count]);
1202                return Err(failure);
1203            }
1204            fds[count] = resolver;
1205            count += 1;
1206
1207            // Opened after the maps are written, so each names a namespace this child now owns.
1208            // `pid_for_children` is the PID namespace the holder is pid 1 of; this child itself
1209            // stays in the runtime's.
1210            for namespace in [
1211                c"/proc/self/ns/user",
1212                c"/proc/self/ns/net",
1213                c"/proc/self/ns/pid_for_children",
1214            ] {
1215                let fd = libc::open(namespace.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC);
1216                if fd < 0 {
1217                    let failure = CreationFailure::at(CreationStep::NamespaceHandles);
1218                    close_all(&fds[..count]);
1219                    return Err(failure);
1220                }
1221                fds[count] = fd;
1222                count += 1;
1223            }
1224
1225            let sent = send_fds(sock_fd, &fds[..count]);
1226            let failure = CreationFailure::at(CreationStep::HandOff);
1227            close_all(&fds[..count]);
1228            sent.map_err(|()| failure)
1229        }
1230    }
1231
1232    /// The holder's whole life: pid 1 of the session's PID namespace, which runs no capsule code
1233    /// and forks nothing.
1234    ///
1235    /// It ignores `SIGCHLD`, so the kernel reaps every process reparented to it as soon as it
1236    /// exits; closes every descriptor except `lifeline_fd`, the read end of the pipe whose write
1237    /// end the runtime holds (`EstablishedNetns`); and blocks reading it. The read returns
1238    /// end-of-file once no process holds the write end — `SessionNetns::end`, its `Drop`, or a
1239    /// `mur` that died by any means — and the holder exits. When pid 1 of a PID namespace exits,
1240    /// the kernel kills every process left in it and no process can be forked into it again.
1241    ///
1242    /// The creator closed its copy of the write end before forking this process, so the holder
1243    /// never holds its own lifeline even where `close_range` is refused;
1244    /// [`crate::cgroup::close_range`] then closes the rest one descriptor at a time.
1245    ///
1246    /// # Safety
1247    /// Post-`fork()` child context only; every call is async-signal-safe and nothing allocates.
1248    unsafe fn run_holder(lifeline_fd: RawFd) -> ! {
1249        // SAFETY: the caller's contract; each call takes integers or live stack locals.
1250        unsafe {
1251            let mut ignore: libc::sigaction = std::mem::zeroed();
1252            ignore.sa_sigaction = libc::SIG_IGN;
1253            libc::sigaction(libc::SIGCHLD, &ignore, std::ptr::null_mut());
1254            libc::prctl(libc::PR_SET_NAME, c"mur-pidns-init".as_ptr());
1255            // Pins no mount of the runtime's working directory.
1256            libc::chdir(c"/".as_ptr());
1257            let lifeline = lifeline_fd as libc::c_uint;
1258            if lifeline > 0 {
1259                crate::cgroup::close_range(0, lifeline - 1);
1260            }
1261            crate::cgroup::close_range(lifeline + 1, libc::c_uint::MAX);
1262            let mut byte = 0u8;
1263            loop {
1264                let read = libc::read(lifeline_fd, (&raw mut byte).cast(), 1);
1265                if read < 0 && *libc::__errno_location() == libc::EINTR {
1266                    continue;
1267                }
1268                if read > 0 {
1269                    continue;
1270                }
1271                break;
1272            }
1273            libc::_exit(0)
1274        }
1275    }
1276
1277    /// Enters the session's namespaces: `setns(user_fd, CLONE_NEWUSER)`, then
1278    /// `setns(net_fd, CLONE_NEWNET)`. Returns the `errno` of whichever failed.
1279    ///
1280    /// The order is required. Joining a network namespace needs `CAP_SYS_ADMIN` in the user
1281    /// namespace that owns it, which the caller holds only once it is inside that user namespace;
1282    /// joining the user namespace needs the caller to be single-threaded and its euid to own the
1283    /// namespace, which a command's forked child is and does.
1284    ///
1285    /// # Safety
1286    /// Post-`fork()`, pre-exec child context only. Both descriptors must be open; the
1287    /// [`SessionNetnsJoin`] they came from keeps them so.
1288    pub(crate) unsafe fn join_session_netns(user_fd: RawFd, net_fd: RawFd) -> io::Result<()> {
1289        // SAFETY: `setns` takes two integers and dereferences nothing; `last_os_error` reads
1290        // errno and does not allocate.
1291        unsafe {
1292            if libc::setns(user_fd, libc::CLONE_NEWUSER) != 0 {
1293                return Err(io::Error::last_os_error());
1294            }
1295            if libc::setns(net_fd, libc::CLONE_NEWNET) != 0 {
1296                return Err(io::Error::last_os_error());
1297            }
1298        }
1299        Ok(())
1300    }
1301
1302    /// What the creator handed over: the namespace sockets, ready for the egress proxy, the
1303    /// three namespace handles, and the write end of the holder's lifeline.
1304    ///
1305    /// Dropping it closes the lifeline, which ends the holder and with it the PID namespace.
1306    pub(crate) struct CreatedNetns {
1307        sockets: Vec<OwnedFd>,
1308        user_ns: OwnedFd,
1309        net_ns: OwnedFd,
1310        pid_ns: OwnedFd,
1311        lifeline: OwnedFd,
1312    }
1313
1314    impl CreatedNetns {
1315        /// `(user_ns, net_ns)`, for [`join_session_netns`] in a child forked while this is held.
1316        pub(crate) fn namespace_handles(&self) -> (RawFd, RawFd) {
1317            (self.user_ns.as_raw_fd(), self.net_ns.as_raw_fd())
1318        }
1319
1320        /// The PID namespace's handle, for `setns(2)` in a child forked while this is held.
1321        pub(crate) fn pid_namespace_handle(&self) -> RawFd {
1322            self.pid_ns.as_raw_fd()
1323        }
1324
1325        /// Starts the session's egress proxy on the namespace sockets.
1326        ///
1327        /// On failure the sockets and the namespace handles close here, which leaves no process
1328        /// and no descriptor holding the namespace.
1329        pub(super) fn serve(
1330            self,
1331            policy: crate::egress_proxy::EgressPolicy,
1332        ) -> Result<EstablishedNetns, String> {
1333            let proxy = crate::egress_proxy::start_egress_proxy(self.sockets, policy)
1334                .map_err(|error| format!("starting the session's egress proxy failed: {error}"))?;
1335            Ok(EstablishedNetns {
1336                user_ns: Arc::new(self.user_ns),
1337                net_ns: Arc::new(self.net_ns),
1338                pid_ns: Arc::new(self.pid_ns),
1339                lifeline: self.lifeline,
1340                proxy,
1341            })
1342        }
1343    }
1344
1345    /// An established session namespace: the three handles commands join through, the write end
1346    /// of the holder's lifeline, and the proxy serving its listeners.
1347    ///
1348    /// The lifeline is held here and nowhere else in the runtime: a [`SessionNetnsJoin`] carries
1349    /// only the handles, so a `Command` that outlives the session keeps no holder alive.
1350    #[derive(Debug)]
1351    pub(super) struct EstablishedNetns {
1352        user_ns: Arc<OwnedFd>,
1353        net_ns: Arc<OwnedFd>,
1354        pid_ns: Arc<OwnedFd>,
1355        lifeline: OwnedFd,
1356        proxy: crate::egress_proxy::EgressProxyHandle,
1357    }
1358
1359    impl EstablishedNetns {
1360        pub(super) fn join(&self) -> SessionNetnsJoin {
1361            SessionNetnsJoin {
1362                user_ns: Arc::clone(&self.user_ns),
1363                net_ns: Arc::clone(&self.net_ns),
1364                pid_ns: Arc::clone(&self.pid_ns),
1365            }
1366        }
1367
1368        /// Stops the proxy, closes the holder's lifeline and drops this session's hold on the
1369        /// namespace handles.
1370        pub(super) fn release(self) {
1371            self.proxy.shutdown();
1372            drop(self.lifeline);
1373        }
1374    }
1375
1376    /// Forks the creator child, waits for it, and takes what it handed over.
1377    ///
1378    /// The creator is a child of its own rather than a command's `pre_exec` step, so no command is
1379    /// special: a command that fails cannot take the session's namespace with it, and creation
1380    /// never depends on what any command is.
1381    ///
1382    /// A failed step is [`NamespaceAttemptError::Step`] with the step and the `errno` the creator
1383    /// reported; every way the creator itself could not run or report is
1384    /// [`NamespaceAttemptError::Creator`].
1385    pub(crate) fn create_session_netns(
1386        plan: &CapsuleNetnsPlan,
1387    ) -> Result<CreatedNetns, NamespaceAttemptError> {
1388        // Checked here because the creator indexes a fixed buffer of this size and must not be the
1389        // place an oversized plan is discovered.
1390        if plan.socket_count() > MAX_NAMESPACE_SOCKETS {
1391            return Err(NamespaceAttemptError::Creator(format!(
1392                "{} namespace sockets requested, more than the {MAX_NAMESPACE_SOCKETS} one \
1393                 hand-off carries",
1394                plan.socket_count()
1395            )));
1396        }
1397
1398        let mut pair = [0i32; 2];
1399        // SAFETY: `pair` is a 2-element stack array, exactly the size `socketpair` writes into.
1400        let rc = unsafe {
1401            libc::socketpair(
1402                libc::AF_UNIX,
1403                libc::SOCK_DGRAM | libc::SOCK_CLOEXEC,
1404                0,
1405                pair.as_mut_ptr(),
1406            )
1407        };
1408        if rc != 0 {
1409            return Err(NamespaceAttemptError::Creator(format!(
1410                "socketpair() for the namespace hand-off failed: {}",
1411                io::Error::last_os_error()
1412            )));
1413        }
1414        // SAFETY: both fds were just returned by the successful `socketpair` above and nothing
1415        // else owns them.
1416        let (parent_end, creator_end) =
1417            unsafe { (OwnedFd::from_raw_fd(pair[0]), OwnedFd::from_raw_fd(pair[1])) };
1418        let creator_fd = creator_end.as_raw_fd();
1419
1420        // The holder's lifeline. Both ends `O_CLOEXEC`, so no program the runtime execs inherits
1421        // either; the holder keeps the read end and the runtime keeps only the write end.
1422        let mut lifeline = [0i32; 2];
1423        // SAFETY: `lifeline` is a 2-element stack array, exactly the size `pipe2` writes into.
1424        if unsafe { libc::pipe2(lifeline.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
1425            return Err(NamespaceAttemptError::Creator(format!(
1426                "pipe2() for the PID namespace holder's lifeline failed: {}",
1427                io::Error::last_os_error()
1428            )));
1429        }
1430        // SAFETY: both fds were just returned by the successful `pipe2` and nothing else owns
1431        // them.
1432        let (lifeline_read, lifeline_write) = unsafe {
1433            (
1434                OwnedFd::from_raw_fd(lifeline[0]),
1435                OwnedFd::from_raw_fd(lifeline[1]),
1436            )
1437        };
1438        let lifeline_fd = lifeline_read.as_raw_fd();
1439        let lifeline_write_fd = lifeline_write.as_raw_fd();
1440
1441        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
1442        // touches nothing but async-signal-safe primitives. The child runs only
1443        // `create_capsule_netns` — `unshare`, `prctl`, `open`/`write`/`close`, `fork`, `socket`,
1444        // `ioctl`, `bind`, `listen`, `setsockopt`, `sendto`/`recv`/`sendmsg` — then `send` and
1445        // `_exit`, and the holder it forks only `sigaction`, `prctl`, `chdir`, `close_range` (or
1446        // `getrlimit` and `close`), `read` and `_exit`, with no allocation, no locks and no stdio.
1447        let pid = unsafe { libc::fork() };
1448        if pid < 0 {
1449            return Err(NamespaceAttemptError::Creator(format!(
1450                "fork() for the namespace creator failed: {}",
1451                io::Error::last_os_error()
1452            )));
1453        }
1454        if pid == 0 {
1455            // SAFETY: forked-child context, syscalls only; both branches end in `_exit`, so no
1456            // destructor of the parent's state runs here.
1457            unsafe {
1458                match create_capsule_netns(plan, creator_fd, lifeline_fd, lifeline_write_fd) {
1459                    Ok(()) => libc::_exit(0),
1460                    Err(failure) => {
1461                        let record = failure.encode();
1462                        libc::send(creator_fd, record.as_ptr().cast(), record.len(), 0);
1463                        libc::_exit(1);
1464                    }
1465                }
1466            }
1467        }
1468        drop(creator_end);
1469        drop(lifeline_read);
1470
1471        // The hand-off is read only after the creator is reaped: a `SOCK_DGRAM` pair reports no
1472        // end-of-file when its peer closes, so reading first would block forever on a creator that
1473        // died before sending, while the message a creator did send stays queued after it exits.
1474        let status = reap_child(pid).map_err(|error| {
1475            NamespaceAttemptError::Creator(format!(
1476                "waiting for the namespace creator failed: {error}"
1477            ))
1478        })?;
1479        match receive_session_netns(parent_end.as_raw_fd(), plan.socket_count())
1480            .map_err(NamespaceAttemptError::Creator)?
1481        {
1482            Received::Handles(mut fds) => {
1483                let pid_ns = fds.pop().expect("the count was checked on receipt");
1484                let net_ns = fds.pop().expect("the count was checked on receipt");
1485                let user_ns = fds.pop().expect("the count was checked on receipt");
1486                Ok(CreatedNetns {
1487                    sockets: fds,
1488                    user_ns,
1489                    net_ns,
1490                    pid_ns,
1491                    lifeline: lifeline_write,
1492                })
1493            }
1494            Received::Failure(failure) => Err(failure.into()),
1495            Received::Nothing => Err(NamespaceAttemptError::Creator(format!(
1496                "the namespace creator exited without handing anything over ({status})"
1497            ))),
1498        }
1499    }
1500
1501    /// Reaps the child `pid`, retrying on `EINTR`, and renders how it ended.
1502    pub(crate) fn reap_child(pid: libc::pid_t) -> io::Result<String> {
1503        let mut status: libc::c_int = 0;
1504        loop {
1505            // SAFETY: `pid` is a child this process forked; `status` is a live local.
1506            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
1507            if waited == pid {
1508                break;
1509            }
1510            let error = io::Error::last_os_error();
1511            if error.kind() != io::ErrorKind::Interrupted {
1512                return Err(error);
1513            }
1514        }
1515        Ok(if libc::WIFEXITED(status) {
1516            format!("exit status {}", libc::WEXITSTATUS(status))
1517        } else if libc::WIFSIGNALED(status) {
1518            format!("killed by signal {}", libc::WTERMSIG(status))
1519        } else {
1520            format!("wait status {status}")
1521        })
1522    }
1523
1524    /// Encodes and decodes a failure record as the creator and the parent do, as the error the
1525    /// parent returns for it.
1526    #[cfg(test)]
1527    pub(super) fn decode_failure_record(step: u32, errno: i32) -> Option<NamespaceAttemptError> {
1528        let record = encode_step_record(step, errno);
1529        CreationFailure::decode(&record).map(|failure| {
1530            assert_eq!(failure.encode(), record);
1531            failure.into()
1532        })
1533    }
1534
1535    /// The creator's one message.
1536    enum Received {
1537        /// `[tcp listeners…, udp53, user_ns, net_ns, pid_ns]`, every one `CLOEXEC`.
1538        Handles(Vec<OwnedFd>),
1539        Failure(CreationFailure),
1540        Nothing,
1541    }
1542
1543    /// Writes `setgroups=deny` and the identity `uid_map`/`gid_map`, reopening the dumpable
1544    /// window around them.
1545    ///
1546    /// The dumpable dance is not optional and is `sealed`'s hardest-won lesson, reused here
1547    /// rather than rediscovered: `mur` marks itself non-dumpable at startup, the flag is inherited
1548    /// across `fork()`, and the kernel reassigns every `/proc/<pid>/*` entry of a non-dumpable
1549    /// task to root — including `uid_map`. Without this the namespace is created and can never be
1550    /// owned, and the failure reads as a host id-mapping policy problem on a host whose policy is
1551    /// perfectly fine.
1552    ///
1553    /// # Safety
1554    /// Post-`fork()`, pre-exec child context only.
1555    unsafe fn write_identity_maps(uid: u32, gid: u32) -> Result<(), ()> {
1556        // SAFETY: caller's contract; every call below is async-signal-safe.
1557        unsafe {
1558            let previous = crate::sealed::make_dumpable_for_map_writes();
1559            let _ = crate::sealed::write_decimal_map(c"/proc/self/setgroups", None, 0);
1560            let mapped = crate::sealed::write_decimal_map(c"/proc/self/uid_map", Some(uid), uid)
1561                .is_ok()
1562                && crate::sealed::write_decimal_map(c"/proc/self/gid_map", Some(gid), gid).is_ok();
1563            crate::sealed::restore_dumpable(previous);
1564            if mapped {
1565                Ok(())
1566            } else {
1567                Err(())
1568            }
1569        }
1570    }
1571
1572    /// `ip link set lo up`, via the legacy `ifreq` ioctl rather than netlink.
1573    ///
1574    /// The ioctl is a fixed-size struct with no message framing, so it is markedly simpler to do
1575    /// allocation-free inside `pre_exec` than the equivalent `RTM_NEWLINK`. It is not deprecated
1576    /// for this purpose, and every distribution's `ifconfig` still uses it.
1577    ///
1578    /// # Safety
1579    /// Post-`fork()`, pre-exec child context only.
1580    unsafe fn bring_loopback_up() -> Result<(), ()> {
1581        // SAFETY: `ifr` is a live, zeroed `ifreq` whose name field is set to "lo"; both ioctls
1582        // read and write only within it, and the socket is closed on every path.
1583        unsafe {
1584            let sock = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
1585            if sock < 0 {
1586                return Err(());
1587            }
1588            let mut ifr: libc::ifreq = std::mem::zeroed();
1589            ifr.ifr_name[0] = b'l' as libc::c_char;
1590            ifr.ifr_name[1] = b'o' as libc::c_char;
1591            if libc::ioctl(sock, libc::SIOCGIFFLAGS, &mut ifr) < 0 {
1592                libc::close(sock);
1593                return Err(());
1594            }
1595            ifr.ifr_ifru.ifru_flags |= (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short;
1596            let rc = libc::ioctl(sock, libc::SIOCSIFFLAGS, &ifr);
1597            libc::close(sock);
1598            if rc < 0 {
1599                return Err(());
1600            }
1601            Ok(())
1602        }
1603    }
1604
1605    /// `ip route add local default dev lo`, over raw netlink.
1606    ///
1607    /// A route of type `RTN_LOCAL` in table `local` tells the kernel that the destination is one
1608    /// of *its own* addresses, so a `connect()` to any address is looped back and delivered to a
1609    /// listening socket in this namespace — with the original destination preserved and
1610    /// recoverable by `getsockname(2)`. That is the whole interception mechanism: no netfilter, no
1611    /// `iptables` binary, and no rule set that can drift out of step with the manifest. A port
1612    /// nothing is bound to is refused immediately by the namespace instead of stalling through the
1613    /// kernel's full SYN-retry schedule against a blackhole.
1614    ///
1615    /// IPv4 only, deliberately. A capsule's IPv6 destinations have no route at all and fail with
1616    /// `ENETUNREACH`, and the proxy reaches every upstream over the host's own stack, so nothing
1617    /// a capsule can legitimately ask for depends on IPv6 inside the namespace.
1618    ///
1619    /// # Safety
1620    /// Post-`fork()`, pre-exec child context only. The request is built in a fixed stack buffer.
1621    unsafe fn add_local_default_route() -> Result<(), ()> {
1622        // SAFETY: `request` and `address` are live, correctly-sized stack locals; the socket is
1623        // closed on every path.
1624        unsafe {
1625            let mut request = [0u8; ROUTE_REQUEST_LEN];
1626            request[0..4].copy_from_slice(&(ROUTE_REQUEST_LEN as u32).to_ne_bytes());
1627            request[4..6].copy_from_slice(&RTM_NEWROUTE.to_ne_bytes());
1628            request[6..8].copy_from_slice(
1629                &(NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL).to_ne_bytes(),
1630            );
1631            request[8..12].copy_from_slice(&1u32.to_ne_bytes()); // sequence
1632            request[12..16].copy_from_slice(&0u32.to_ne_bytes()); // pid: the kernel assigns
1633
1634            let rtm = NLMSGHDR_LEN;
1635            request[rtm] = libc::AF_INET as u8;
1636            request[rtm + 1] = 0; // dst_len 0 — a default route
1637            request[rtm + 2] = 0; // src_len
1638            request[rtm + 3] = 0; // tos
1639            request[rtm + 4] = RT_TABLE_LOCAL;
1640            request[rtm + 5] = RTPROT_BOOT;
1641            request[rtm + 6] = RT_SCOPE_HOST;
1642            request[rtm + 7] = RTN_LOCAL;
1643            request[rtm + 8..rtm + 12].copy_from_slice(&0u32.to_ne_bytes()); // rtm_flags
1644
1645            let attr = rtm + RTMSG_LEN;
1646            request[attr..attr + 2].copy_from_slice(&(RTATTR_OIF_LEN as u16).to_ne_bytes());
1647            request[attr + 2..attr + 4].copy_from_slice(&RTA_OIF.to_ne_bytes());
1648            request[attr + 4..attr + 8].copy_from_slice(&LOOPBACK_IFINDEX.to_ne_bytes());
1649
1650            let sock = libc::socket(
1651                libc::AF_NETLINK,
1652                libc::SOCK_RAW | libc::SOCK_CLOEXEC,
1653                libc::NETLINK_ROUTE,
1654            );
1655            if sock < 0 {
1656                return Err(());
1657            }
1658            let mut address: libc::sockaddr_nl = std::mem::zeroed();
1659            address.nl_family = libc::AF_NETLINK as u16;
1660            let sent = libc::sendto(
1661                sock,
1662                request.as_ptr().cast(),
1663                ROUTE_REQUEST_LEN,
1664                0,
1665                std::ptr::addr_of!(address).cast(),
1666                std::mem::size_of::<libc::sockaddr_nl>() as libc::socklen_t,
1667            );
1668            if sent < 0 {
1669                libc::close(sock);
1670                return Err(());
1671            }
1672
1673            // Reading the ACK is not optional: netlink reports the real outcome here, and a
1674            // request that was accepted for delivery but rejected by the kernel would otherwise
1675            // look like success and leave the capsule with a namespace it cannot resolve through.
1676            let mut response = [0u8; 256];
1677            let received = libc::recv(sock, response.as_mut_ptr().cast(), response.len(), 0);
1678            libc::close(sock);
1679            if received < (NLMSGHDR_LEN + 4) as isize {
1680                return Err(());
1681            }
1682            if u16::from_ne_bytes([response[4], response[5]]) == NLMSG_ERROR {
1683                let code = i32::from_ne_bytes([
1684                    response[NLMSGHDR_LEN],
1685                    response[NLMSGHDR_LEN + 1],
1686                    response[NLMSGHDR_LEN + 2],
1687                    response[NLMSGHDR_LEN + 3],
1688                ]);
1689                // Netlink spells a plain ACK as an error message carrying code 0.
1690                if code != 0 {
1691                    return Err(());
1692                }
1693            }
1694            Ok(())
1695        }
1696    }
1697
1698    /// A listening TCP socket on `0.0.0.0:port` inside the namespace, or `-1`.
1699    ///
1700    /// The wildcard address is what pairs with the local default route: every address is locally
1701    /// deliverable, so this one socket receives connections addressed to *any* host on this port,
1702    /// and `getsockname(2)` on each accepted connection recovers which one the capsule dialled.
1703    ///
1704    /// # Safety
1705    /// Post-`fork()`, pre-exec child context only.
1706    unsafe fn bind_tcp_listener(port: u16) -> RawFd {
1707        // SAFETY: `address` is a live, zeroed `sockaddr_in`; every failure path closes the fd.
1708        unsafe {
1709            let fd = libc::socket(libc::AF_INET, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0);
1710            if fd < 0 {
1711                return -1;
1712            }
1713            let mut address: libc::sockaddr_in = std::mem::zeroed();
1714            address.sin_family = libc::AF_INET as libc::sa_family_t;
1715            address.sin_port = port.to_be();
1716            address.sin_addr.s_addr = libc::INADDR_ANY.to_be();
1717            if libc::bind(
1718                fd,
1719                std::ptr::addr_of!(address).cast(),
1720                std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
1721            ) != 0
1722                || libc::listen(fd, 128) != 0
1723            {
1724                libc::close(fd);
1725                return -1;
1726            }
1727            fd
1728        }
1729    }
1730
1731    /// A UDP socket bound to `0.0.0.0:53` inside the namespace with `IP_PKTINFO` enabled, or
1732    /// `-1`.
1733    ///
1734    /// The wildcard bind pairs with the local default route: whatever address `/etc/resolv.conf`
1735    /// names, the query is delivered to this one socket. `IP_PKTINFO` is then required rather
1736    /// than a nicety — it is how the proxy recovers the address the query was *sent to*, so its
1737    /// reply can carry that same address as the source. A resolver discards an answer that
1738    /// appears to come from a server it never asked, and the symptom of getting this wrong is
1739    /// "DNS silently does not work".
1740    ///
1741    /// # Safety
1742    /// Post-`fork()`, pre-exec child context only.
1743    unsafe fn bind_dns_socket() -> RawFd {
1744        // SAFETY: as `bind_tcp_listener`.
1745        unsafe {
1746            let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
1747            if fd < 0 {
1748                return -1;
1749            }
1750            let enable: libc::c_int = 1;
1751            if libc::setsockopt(
1752                fd,
1753                libc::IPPROTO_IP,
1754                libc::IP_PKTINFO,
1755                std::ptr::addr_of!(enable).cast(),
1756                std::mem::size_of::<libc::c_int>() as libc::socklen_t,
1757            ) != 0
1758            {
1759                libc::close(fd);
1760                return -1;
1761            }
1762            let mut address: libc::sockaddr_in = std::mem::zeroed();
1763            address.sin_family = libc::AF_INET as libc::sa_family_t;
1764            address.sin_port = super::DNS_PORT_BE;
1765            address.sin_addr.s_addr = libc::INADDR_ANY.to_be();
1766            if libc::bind(
1767                fd,
1768                std::ptr::addr_of!(address).cast(),
1769                std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
1770            ) != 0
1771            {
1772                libc::close(fd);
1773                return -1;
1774            }
1775            fd
1776        }
1777    }
1778
1779    /// # Safety
1780    /// Post-`fork()`, pre-exec child context only.
1781    unsafe fn close_all(fds: &[RawFd]) {
1782        for fd in fds {
1783            if *fd >= 0 {
1784                // SAFETY: each entry was returned by a successful `socket` or `open` call above.
1785                unsafe { libc::close(*fd) };
1786            }
1787        }
1788    }
1789
1790    /// Enough room for [`MAX_SESSION_NETNS_FDS`] descriptors plus the `cmsghdr`.
1791    const CMSG_BUFFER_LEN: usize = 128;
1792
1793    // `send_fds`/`receive_session_netns` size `msg_controllen` from
1794    // `libc::CMSG_SPACE(descriptor_bytes)`, independently of `CMSG_BUFFER_LEN`, so a bump of
1795    // `MAX_SESSION_NETNS_FDS` (via `egress_proxy::MAX_EGRESS_TCP_PORTS`) could otherwise make that
1796    // computed length exceed the fixed-size `CmsgBuffer` below, and the CMSG macros would then
1797    // write past its end. Pinned at compile time instead.
1798    const _: () = assert!(
1799        // SAFETY: `CMSG_SPACE` is pure arithmetic on its argument (see libc's definition); it is
1800        // marked `unsafe` only for macro-generation consistency with pointer-taking neighbours
1801        // like `CMSG_DATA`, not because this call touches memory.
1802        unsafe {
1803            libc::CMSG_SPACE((MAX_SESSION_NETNS_FDS * std::mem::size_of::<RawFd>()) as u32) as usize
1804        } <= CMSG_BUFFER_LEN,
1805        "CMSG_BUFFER_LEN is too small to hold MAX_SESSION_NETNS_FDS descriptors plus the \
1806         cmsghdr; raise it to match"
1807    );
1808
1809    /// A `cmsghdr` buffer with the alignment the CMSG macros require.
1810    ///
1811    /// A bare `[u8; N]` is only 1-byte aligned and `CMSG_FIRSTHDR` casts it straight to
1812    /// `*mut cmsghdr`: on a debug build that is a misaligned-pointer panic, and on a release build
1813    /// it is undefined behaviour. Found the hard way while prototyping this hand-off.
1814    #[repr(C, align(8))]
1815    struct CmsgBuffer([u8; CMSG_BUFFER_LEN]);
1816
1817    /// Sends every namespace socket and both namespace handles to the parent in one
1818    /// `SCM_RIGHTS` message.
1819    ///
1820    /// One message rather than one per fd, so the parent's receive is a single unambiguous read:
1821    /// a partial hand-off would leave the proxy serving the endpoint but not the resolver, which
1822    /// presents as "DNS mysteriously does not work" rather than as a failure.
1823    ///
1824    /// # Safety
1825    /// Post-`fork()`, pre-exec child context only; `sendmsg` is async-signal-safe and every
1826    /// buffer is a stack local.
1827    pub(crate) unsafe fn send_fds(sock_fd: RawFd, fds: &[RawFd]) -> Result<(), ()> {
1828        // SAFETY: all buffers are live locals outliving the single `sendmsg`; the control buffer
1829        // is 8-byte aligned, which the CMSG macros require.
1830        unsafe {
1831            let mut payload = [0u8; 1];
1832            let mut iov = libc::iovec {
1833                iov_base: payload.as_mut_ptr().cast(),
1834                iov_len: payload.len(),
1835            };
1836            let mut control = CmsgBuffer([0u8; CMSG_BUFFER_LEN]);
1837            let bytes = std::mem::size_of_val(fds) as u32;
1838
1839            let mut msg: libc::msghdr = std::mem::zeroed();
1840            msg.msg_iov = &mut iov;
1841            msg.msg_iovlen = 1;
1842            msg.msg_control = control.0.as_mut_ptr().cast();
1843            msg.msg_controllen = libc::CMSG_SPACE(bytes) as _;
1844
1845            let cmsg = libc::CMSG_FIRSTHDR(&msg);
1846            if cmsg.is_null() {
1847                return Err(());
1848            }
1849            (*cmsg).cmsg_level = libc::SOL_SOCKET;
1850            (*cmsg).cmsg_type = libc::SCM_RIGHTS;
1851            (*cmsg).cmsg_len = libc::CMSG_LEN(bytes) as _;
1852            std::ptr::copy_nonoverlapping(
1853                fds.as_ptr().cast::<u8>(),
1854                libc::CMSG_DATA(cmsg),
1855                bytes as usize,
1856            );
1857
1858            if libc::sendmsg(sock_fd, &msg, 0) < 0 {
1859                return Err(());
1860            }
1861            Ok(())
1862        }
1863    }
1864
1865    /// Parent-side counterpart of [`send_fds`]: takes the creator's one message off the queue.
1866    ///
1867    /// Non-blocking, because the creator has already been reaped by the time this runs: whatever
1868    /// it sent is queued, and an empty queue means it sent nothing. A descriptor count other than
1869    /// `expected_sockets + NAMESPACE_HANDLE_COUNT` is an error rather than a best-effort start,
1870    /// because a proxy missing one of its sockets is a capsule whose network half-works in a way
1871    /// nothing downstream would report. A creator that failed after forking the holder leaves it waiting on the lifeline,
1872    /// whose write end the caller drops on that error path.
1873    fn receive_session_netns(sock_fd: RawFd, expected_sockets: usize) -> Result<Received, String> {
1874        // SAFETY: `sock_fd` is a valid unix socket; all buffers are live locals sized for the
1875        // largest message the creator can send, and the control buffer is 8-byte aligned.
1876        unsafe {
1877            let mut payload = [0u8; STEP_RECORD_LEN];
1878            let mut iov = libc::iovec {
1879                iov_base: payload.as_mut_ptr().cast(),
1880                iov_len: payload.len(),
1881            };
1882            let mut control = CmsgBuffer([0u8; CMSG_BUFFER_LEN]);
1883            let mut msg: libc::msghdr = std::mem::zeroed();
1884            msg.msg_iov = &mut iov;
1885            msg.msg_iovlen = 1;
1886            msg.msg_control = control.0.as_mut_ptr().cast();
1887            msg.msg_controllen = control.0.len() as _;
1888
1889            let received = libc::recvmsg(
1890                sock_fd,
1891                &mut msg,
1892                libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC,
1893            );
1894            if received < 0 {
1895                let error = io::Error::last_os_error();
1896                if error.kind() == io::ErrorKind::WouldBlock {
1897                    return Ok(Received::Nothing);
1898                }
1899                return Err(format!("receiving the namespace hand-off failed: {error}"));
1900            }
1901
1902            let cmsg = libc::CMSG_FIRSTHDR(&msg);
1903            if cmsg.is_null() {
1904                if received as usize == STEP_RECORD_LEN {
1905                    if let Some(failure) = CreationFailure::decode(&payload) {
1906                        return Ok(Received::Failure(failure));
1907                    }
1908                }
1909                return Ok(Received::Nothing);
1910            }
1911            if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
1912                return Err(
1913                    "the namespace hand-off did not arrive as an SCM_RIGHTS message".to_string(),
1914                );
1915            }
1916            let bytes = (*cmsg).cmsg_len - libc::CMSG_LEN(0) as usize;
1917            let count = bytes / std::mem::size_of::<RawFd>();
1918            let data = libc::CMSG_DATA(cmsg).cast::<RawFd>();
1919            // Wrapped in `OwnedFd` before anything is checked, so a mismatched or truncated
1920            // message closes its descriptors on the error path instead of leaking them.
1921            let mut fds = Vec::with_capacity(count);
1922            for index in 0..count {
1923                fds.push(OwnedFd::from_raw_fd(std::ptr::read_unaligned(
1924                    data.add(index),
1925                )));
1926            }
1927            if msg.msg_flags & libc::MSG_CTRUNC != 0 {
1928                return Err("the namespace hand-off was truncated".to_string());
1929            }
1930            if count != expected_sockets + NAMESPACE_HANDLE_COUNT {
1931                return Err(format!(
1932                    "the namespace creator handed over {count} descriptors; {} were expected \
1933                     ({expected_sockets} sockets and {NAMESPACE_HANDLE_COUNT} namespace handles)",
1934                    expected_sockets + NAMESPACE_HANDLE_COUNT
1935                ));
1936            }
1937            Ok(Received::Handles(fds))
1938        }
1939    }
1940}
1941
1942/// [`crate::egress_proxy::EGRESS_DNS_PORT`] in network byte order, so the `pre_exec` window does
1943/// no arithmetic the parent could have done for it.
1944#[cfg(target_os = "linux")]
1945const DNS_PORT_BE: u16 = crate::egress_proxy::EGRESS_DNS_PORT.to_be();
1946
1947#[cfg(test)]
1948mod tests {
1949    use super::*;
1950    use crate::sealed::{ProfileAttachment, UsernsGrant};
1951
1952    fn attachments() -> [ProfileAttachment; 4] {
1953        [
1954            ProfileAttachment::Sealed {
1955                profile: "mur-sealed".to_string(),
1956            },
1957            ProfileAttachment::Other {
1958                profile: "docker-default".to_string(),
1959            },
1960            ProfileAttachment::Unconfined,
1961            ProfileAttachment::NotReported,
1962        ]
1963    }
1964
1965    fn step(step: CreationStep, errno: i32) -> Result<(), NamespaceAttemptError> {
1966        Err(NamespaceAttemptError::Step { step, errno })
1967    }
1968
1969    fn failure(
1970        attempt: Result<(), NamespaceAttemptError>,
1971        grant: UsernsGrant,
1972        attachment: ProfileAttachment,
1973    ) -> EgressNamespaceFailure {
1974        egress_namespace_failure(true, attempt, grant, attachment)
1975            .expect("a failed attempt is a failure")
1976    }
1977
1978    // ---- the decision ----------------------------------------------------------------------
1979
1980    /// A namespace that was created is never refused, whatever AppArmor reads: a container whose
1981    /// own profile permits it, on a host whose restriction reads `1`, gets its namespace.
1982    #[test]
1983    fn a_created_namespace_is_never_refused_whatever_the_readings() {
1984        for grant in UsernsGrant::ALL {
1985            for attachment in attachments() {
1986                assert_eq!(
1987                    egress_namespace_failure(true, Ok(()), *grant, attachment.clone()),
1988                    None,
1989                    "{grant:?} / {attachment:?}"
1990                );
1991            }
1992        }
1993    }
1994
1995    /// A namespace that was not created is refused, whatever AppArmor reads, and the blocker is
1996    /// the same for every reading.
1997    #[test]
1998    fn a_failed_creation_is_refused_whatever_the_readings() {
1999        let attempts = [
2000            (
2001                step(CreationStep::Unshare, libc::EPERM),
2002                EgressNamespaceBlocker::CapabilityGrantMissing,
2003            ),
2004            (
2005                step(CreationStep::Unshare, libc::ENOSPC),
2006                EgressNamespaceBlocker::KernelSupportMissing,
2007            ),
2008            (
2009                Err(NamespaceAttemptError::Creator("fork() failed".to_string())),
2010                EgressNamespaceBlocker::CreatorUnavailable,
2011            ),
2012        ];
2013        for (attempt, blocker) in attempts {
2014            for grant in UsernsGrant::ALL {
2015                for attachment in attachments() {
2016                    let failure =
2017                        egress_namespace_failure(true, attempt.clone(), *grant, attachment.clone())
2018                            .unwrap_or_else(|| {
2019                                panic!("{attempt:?} under {grant:?} / {attachment:?}")
2020                            });
2021                    assert_eq!(failure.blocker, blocker, "{grant:?} / {attachment:?}");
2022                }
2023            }
2024        }
2025    }
2026
2027    #[test]
2028    fn only_an_unshare_the_kernel_cannot_honour_is_missing_kernel_support() {
2029        for errno in [libc::ENOSPC, libc::EINVAL, libc::ENOSYS, libc::EUSERS] {
2030            assert_eq!(
2031                failure(
2032                    step(CreationStep::Unshare, errno),
2033                    UsernsGrant::Withheld,
2034                    ProfileAttachment::Unconfined
2035                )
2036                .blocker,
2037                EgressNamespaceBlocker::KernelSupportMissing,
2038                "errno {errno}"
2039            );
2040        }
2041        for (failed, errno) in [
2042            (CreationStep::Unshare, libc::EPERM),
2043            (CreationStep::Unshare, libc::EACCES),
2044            (CreationStep::IdentityMaps, libc::EPERM),
2045            (CreationStep::Loopback, libc::EPERM),
2046            (CreationStep::LocalRoute, libc::EINVAL),
2047            (CreationStep::Resolver, libc::ENOSPC),
2048        ] {
2049            assert_eq!(
2050                failure(
2051                    step(failed, errno),
2052                    UsernsGrant::Withheld,
2053                    ProfileAttachment::Unconfined
2054                )
2055                .blocker,
2056                EgressNamespaceBlocker::CapabilityGrantMissing,
2057                "{failed:?} errno {errno}"
2058            );
2059        }
2060    }
2061
2062    #[test]
2063    fn a_creator_that_could_not_run_is_unavailable_and_keeps_its_detail() {
2064        let detail = "fork() for the namespace creator failed: Resource temporarily unavailable \
2065                      (os error 11)";
2066        let failure = failure(
2067            Err(NamespaceAttemptError::Creator(detail.to_string())),
2068            UsernsGrant::Withheld,
2069            ProfileAttachment::Unconfined,
2070        );
2071        assert_eq!(failure.blocker, EgressNamespaceBlocker::CreatorUnavailable);
2072        assert_eq!(failure.errno, None);
2073        let reason = failure.reason();
2074        assert!(reason.starts_with(detail), "{reason}");
2075        assert!(!reason.contains("apparmor_parser"), "{reason}");
2076        assert!(!reason.contains("CONFIG_USER_NS"), "{reason}");
2077        assert!(!reason.contains("user.max_user_namespaces"), "{reason}");
2078    }
2079
2080    #[test]
2081    fn non_linux_is_never_refused_for_a_mechanism_it_never_had() {
2082        for attempt in [
2083            Ok(()),
2084            step(CreationStep::Unshare, libc::EPERM),
2085            Err(NamespaceAttemptError::Creator("fork() failed".to_string())),
2086        ] {
2087            assert_eq!(
2088                egress_namespace_failure(
2089                    false,
2090                    attempt,
2091                    UsernsGrant::Withheld,
2092                    ProfileAttachment::NotReported
2093                ),
2094                None
2095            );
2096        }
2097        #[cfg(not(target_os = "linux"))]
2098        assert_eq!(detect_egress_namespace_failure(), None);
2099    }
2100
2101    #[test]
2102    fn the_check_refuses_exactly_a_capsule_that_can_spawn_on_a_failing_host() {
2103        let failure = failure(
2104            step(CreationStep::Unshare, libc::EPERM),
2105            UsernsGrant::Withheld,
2106            ProfileAttachment::Unconfined,
2107        );
2108        match check_egress_namespace(true, Some(&failure)) {
2109            Err(crate::errors::RuntimeError::EgressNamespaceUnavailable {
2110                blocker,
2111                reason,
2112                ..
2113            }) => {
2114                assert_eq!(blocker, failure.blocker);
2115                assert_eq!(reason, failure.reason());
2116            }
2117            other => panic!("expected the E-CAP-005 refusal, got {other:?}"),
2118        }
2119        assert!(check_egress_namespace(false, Some(&failure)).is_ok());
2120        assert!(check_egress_namespace(true, None).is_ok());
2121        assert!(check_egress_namespace(false, None).is_ok());
2122    }
2123
2124    // ---- the reason ------------------------------------------------------------------------
2125
2126    #[test]
2127    fn an_unconfined_binary_on_a_restricted_host_is_told_the_profile_before_the_sysctl() {
2128        let reason = failure(
2129            step(CreationStep::Unshare, libc::EPERM),
2130            UsernsGrant::Withheld,
2131            ProfileAttachment::Unconfined,
2132        )
2133        .reason();
2134        assert!(
2135            reason.starts_with(
2136                "unshare(CLONE_NEWUSER | CLONE_NEWNET) failed with EPERM (Operation not \
2137                 permitted)."
2138            ),
2139            "{reason}"
2140        );
2141        assert!(
2142            reason.contains("kernel.apparmor_restrict_unprivileged_userns=1"),
2143            "{reason}"
2144        );
2145        let profile = reason
2146            .find("apparmor_parser -r")
2147            .expect("the profile install command must be named");
2148        let sysctl = reason
2149            .find("kernel.apparmor_restrict_unprivileged_userns=0")
2150            .expect("the fallback must still be named for hosts that cannot load a profile");
2151        assert!(
2152            profile < sysctl,
2153            "the profile install must come first: {reason}"
2154        );
2155        assert!(reason.contains("LAST RESORT"), "{reason}");
2156        assert!(reason.contains("every program on the machine"), "{reason}");
2157        assert!(
2158            reason.contains("scripts/install-dev-apparmor.sh"),
2159            "{reason}"
2160        );
2161        assert!(
2162            reason.ends_with(
2163                "Host readings: kernel.apparmor_restrict_unprivileged_userns=1; AppArmor profile \
2164                 confining mur: unconfined."
2165            ),
2166            "{reason}"
2167        );
2168    }
2169
2170    #[test]
2171    fn a_binary_another_profile_confines_is_given_the_container_remedy_not_the_sysctl() {
2172        let reason = failure(
2173            step(CreationStep::Unshare, libc::EPERM),
2174            UsernsGrant::Withheld,
2175            ProfileAttachment::Other {
2176                profile: "docker-default".to_string(),
2177            },
2178        )
2179        .reason();
2180        assert!(reason.contains("`docker-default`"), "{reason}");
2181        assert!(!reason.contains("--cap-add"), "{reason}");
2182        assert!(reason.contains("seccomp profile"), "{reason}");
2183        assert!(
2184            reason.contains("unshare(CLONE_NEWUSER) and setns"),
2185            "{reason}"
2186        );
2187        assert!(reason.contains("seccompProfile: Localhost"), "{reason}");
2188        assert!(
2189            !reason.contains("kernel.apparmor_restrict_unprivileged_userns=0"),
2190            "{reason}"
2191        );
2192        assert!(!reason.contains("apparmor_parser"), "{reason}");
2193        assert!(
2194            reason.ends_with("AppArmor profile confining mur: docker-default."),
2195            "{reason}"
2196        );
2197    }
2198
2199    #[test]
2200    fn a_failure_under_the_sealed_profile_points_at_a_stale_load() {
2201        let reason = failure(
2202            step(CreationStep::Unshare, libc::EPERM),
2203            UsernsGrant::ProfileConfining,
2204            ProfileAttachment::Sealed {
2205                profile: "mur-sealed".to_string(),
2206            },
2207        )
2208        .reason();
2209        assert!(
2210            reason.contains("sudo apparmor_parser -r /etc/apparmor.d/mur-sealed"),
2211            "{reason}"
2212        );
2213        assert!(reason.contains("stale"), "{reason}");
2214        assert!(!reason.contains("LAST RESORT"), "{reason}");
2215    }
2216
2217    #[test]
2218    fn without_the_restriction_the_cause_is_not_apparmor() {
2219        for (grant, readings) in [
2220            (
2221                UsernsGrant::RestrictionDisabledHostWide,
2222                "kernel.apparmor_restrict_unprivileged_userns=0",
2223            ),
2224            (UsernsGrant::ApparmorAbsent, "AppArmor not enabled"),
2225        ] {
2226            for attachment in [
2227                ProfileAttachment::Unconfined,
2228                ProfileAttachment::NotReported,
2229            ] {
2230                let reason =
2231                    failure(step(CreationStep::Unshare, libc::EPERM), grant, attachment).reason();
2232                assert!(
2233                    !reason.contains("apparmor_restrict_unprivileged_userns=1"),
2234                    "{reason}"
2235                );
2236                assert!(!reason.contains("apparmor_parser"), "{reason}");
2237                assert!(!reason.contains("--cap-add"), "{reason}");
2238                assert!(!reason.contains("CAP_SYS_ADMIN"), "{reason}");
2239                assert!(
2240                    reason.contains("seccomp profile is the usual blocker"),
2241                    "{reason}"
2242                );
2243                assert!(
2244                    reason.contains("unshare(CLONE_NEWUSER) and setns"),
2245                    "{reason}"
2246                );
2247                assert!(reason.contains("--security-opt seccomp=<file>"), "{reason}");
2248                assert!(
2249                    reason.contains(&format!("Host readings: {readings};")),
2250                    "{reason}"
2251                );
2252            }
2253        }
2254    }
2255
2256    /// On a host without AppArmor `/proc/self/attr/current` carries another module's label, which
2257    /// is no AppArmor profile to blame.
2258    #[test]
2259    fn another_security_modules_label_is_not_blamed_as_an_apparmor_profile() {
2260        let reason = failure(
2261            step(CreationStep::Unshare, libc::EPERM),
2262            UsernsGrant::ApparmorAbsent,
2263            ProfileAttachment::Other {
2264                profile: "unconfined_u:unconfined_r:unconfined_t:s0".to_string(),
2265            },
2266        )
2267        .reason();
2268        assert!(reason.contains("not AppArmor"), "{reason}");
2269        assert!(!reason.contains("the AppArmor profile `"), "{reason}");
2270    }
2271
2272    #[test]
2273    fn a_namespace_created_and_then_refused_says_which_half_failed() {
2274        let configure = failure(
2275            step(CreationStep::Loopback, libc::EPERM),
2276            UsernsGrant::RestrictionDisabledHostWide,
2277            ProfileAttachment::Unconfined,
2278        )
2279        .reason();
2280        assert!(
2281            configure.starts_with("bringing lo up failed with EPERM"),
2282            "{configure}"
2283        );
2284        assert!(configure.contains("CAP_NET_ADMIN"), "{configure}");
2285
2286        let owned = failure(
2287            step(CreationStep::IdentityMaps, libc::EACCES),
2288            UsernsGrant::RestrictionDisabledHostWide,
2289            ProfileAttachment::Unconfined,
2290        )
2291        .reason();
2292        assert!(
2293            owned.starts_with("writing uid_map/gid_map failed with EACCES (Permission denied)."),
2294            "{owned}"
2295        );
2296        assert!(owned.contains("could not be owned"), "{owned}");
2297    }
2298
2299    #[test]
2300    fn missing_kernel_support_names_the_limit_or_the_config_symbol_and_no_apparmor() {
2301        for (errno, named) in [
2302            (libc::ENOSPC, "user.max_user_namespaces"),
2303            (libc::EINVAL, "CONFIG_USER_NS"),
2304            (libc::ENOSYS, "CONFIG_USER_NS"),
2305            (libc::EUSERS, "nesting limit"),
2306        ] {
2307            let reason = failure(
2308                step(CreationStep::Unshare, errno),
2309                UsernsGrant::Withheld,
2310                ProfileAttachment::Unconfined,
2311            )
2312            .reason();
2313            assert!(reason.contains(named), "{reason}");
2314            assert!(
2315                reason.contains(errno_name(errno).expect("a named errno")),
2316                "{reason}"
2317            );
2318            assert!(!reason.contains("apparmor_parser"), "{reason}");
2319            assert!(!reason.contains("LAST RESORT"), "{reason}");
2320        }
2321    }
2322
2323    #[test]
2324    fn an_unnamed_errno_is_rendered_by_number() {
2325        assert_eq!(errno_name(libc::EPERM), Some("EPERM"));
2326        assert_eq!(errno_name(-7), None);
2327        let failure = failure(
2328            step(CreationStep::HandOff, 4095),
2329            UsernsGrant::Withheld,
2330            ProfileAttachment::Unconfined,
2331        );
2332        assert_eq!(
2333            failure.summary(),
2334            "handing the namespace to the runtime failed with errno 4095"
2335        );
2336    }
2337
2338    #[test]
2339    fn the_summary_names_the_call_and_the_errno() {
2340        let failed = failure(
2341            step(CreationStep::Unshare, libc::EPERM),
2342            UsernsGrant::Withheld,
2343            ProfileAttachment::Unconfined,
2344        );
2345        assert_eq!(
2346            failed.summary(),
2347            "unshare(CLONE_NEWUSER | CLONE_NEWNET) failed with EPERM"
2348        );
2349        let creator = failure(
2350            Err(NamespaceAttemptError::Creator("fork() failed".to_string())),
2351            UsernsGrant::Withheld,
2352            ProfileAttachment::Unconfined,
2353        );
2354        assert_eq!(creator.summary(), "fork() failed");
2355    }
2356
2357    #[test]
2358    fn every_step_round_trips_through_its_code() {
2359        for step in CreationStep::ALL {
2360            assert_eq!(CreationStep::from_code(step as u32), Some(step));
2361        }
2362        assert_eq!(CreationStep::from_code(0), None);
2363        assert_eq!(CreationStep::from_code(99), None);
2364    }
2365
2366    // ---- one probe, one reason -------------------------------------------------------------
2367
2368    /// A step that fails in a session's own creation is refused with exactly the text staging
2369    /// renders for the same step, `errno` and readings.
2370    #[test]
2371    fn a_session_creation_failure_is_refused_in_the_staging_words() {
2372        for (failed, errno, grant, attachment) in [
2373            (
2374                CreationStep::Unshare,
2375                libc::EPERM,
2376                UsernsGrant::Withheld,
2377                ProfileAttachment::Unconfined,
2378            ),
2379            (
2380                CreationStep::LocalRoute,
2381                libc::EPERM,
2382                UsernsGrant::Withheld,
2383                ProfileAttachment::Other {
2384                    profile: "docker-default".to_string(),
2385                },
2386            ),
2387            (
2388                CreationStep::Unshare,
2389                libc::ENOSPC,
2390                UsernsGrant::RestrictionDisabledHostWide,
2391                ProfileAttachment::Unconfined,
2392            ),
2393        ] {
2394            let staging =
2395                egress_namespace_failure(true, step(failed, errno), grant, attachment.clone());
2396            let staging_refusal = check_egress_namespace(true, staging.as_ref())
2397                .expect_err("staging refuses")
2398                .to_string();
2399            let establish = session_netns_creation_error(
2400                NamespaceAttemptError::Step {
2401                    step: failed,
2402                    errno,
2403                },
2404                grant,
2405                attachment,
2406            );
2407            assert_eq!(establish, format!("sandbox: E-CAP-005: {staging_refusal}"));
2408            assert!(
2409                establish.contains(&staging.expect("a failure").reason()),
2410                "{establish}"
2411            );
2412        }
2413    }
2414
2415    #[test]
2416    fn a_session_creator_that_could_not_run_keeps_the_runtime_account() {
2417        let error = session_netns_creation_error(
2418            NamespaceAttemptError::Creator("fork() for the namespace creator failed".to_string()),
2419            UsernsGrant::Withheld,
2420            ProfileAttachment::Unconfined,
2421        );
2422        assert_eq!(
2423            error,
2424            "sandbox: could not create this session's network namespace: fork() for the \
2425             namespace creator failed"
2426        );
2427    }
2428
2429    // ---- the socket plan --------------------------------------------------------------------
2430
2431    #[test]
2432    fn the_socket_count_is_one_per_port_plus_the_resolver() {
2433        let plan = CapsuleNetnsPlan {
2434            tcp_ports: vec![80, 443],
2435            uid: 1000,
2436            gid: 1000,
2437            force_pid_namespace_failure: false,
2438        };
2439        assert_eq!(plan.socket_count(), 3);
2440    }
2441
2442    #[test]
2443    fn a_capsule_with_no_tcp_egress_still_binds_the_resolver() {
2444        // Load-bearing: with nothing bound on 53 a lookup would be a dropped packet, and the
2445        // capsule would stall through glibc's whole retry schedule instead of being told no.
2446        let plan = CapsuleNetnsPlan {
2447            tcp_ports: Vec::new(),
2448            uid: 1000,
2449            gid: 1000,
2450            force_pid_namespace_failure: false,
2451        };
2452        assert_eq!(plan.socket_count(), 1);
2453    }
2454
2455    #[test]
2456    fn the_widest_allowlist_still_fits_one_scm_rights_message() {
2457        let plan = CapsuleNetnsPlan {
2458            tcp_ports: (0..crate::egress_proxy::MAX_EGRESS_TCP_PORTS as u16).collect(),
2459            uid: 1000,
2460            gid: 1000,
2461            force_pid_namespace_failure: false,
2462        };
2463        assert!(plan.socket_count() <= MAX_NAMESPACE_SOCKETS);
2464        // The three namespace handles ride in the same message.
2465        assert_eq!(MAX_SESSION_NETNS_FDS, MAX_NAMESPACE_SOCKETS + 3);
2466    }
2467
2468    // ---- the session's namespace ---------------------------------------------------------
2469
2470    /// An ended session is never given a namespace, including one that never created one.
2471    #[cfg(target_os = "linux")]
2472    #[test]
2473    fn an_ended_session_refuses_to_create_a_namespace() {
2474        let netns = SessionNetns::new();
2475        netns.end();
2476        let refusal = netns
2477            .establish(
2478                &[],
2479                crate::egress_proxy::EgressPolicy::new(Vec::new(), Vec::new()),
2480            )
2481            .expect_err("an ended session creates nothing");
2482        assert!(refusal.contains("session has ended"), "{refusal}");
2483        assert_eq!(netns.creations(), 0);
2484    }
2485
2486    /// One creation serves every later call, and the namespace it made is the one each call
2487    /// is handed; ending it is final and repeatable.
2488    #[cfg(target_os = "linux")]
2489    #[test]
2490    fn a_session_creates_its_namespace_once_and_ends_it_for_good() {
2491        if skip_without_egress_namespace(
2492            "a_session_creates_its_namespace_once_and_ends_it_for_good",
2493        ) {
2494            return;
2495        }
2496        let policy = || crate::egress_proxy::EgressPolicy::new(Vec::new(), Vec::new());
2497        let netns = SessionNetns::new();
2498        assert_eq!(
2499            netns.creations(),
2500            0,
2501            "nothing is created before it is needed"
2502        );
2503
2504        let first = netns
2505            .establish(&[], policy())
2506            .expect("the namespace is created");
2507        let second = netns
2508            .establish(&[], policy())
2509            .expect("the namespace is reused");
2510        assert_eq!(netns.creations(), 1);
2511        let inode = |fd: std::os::fd::RawFd| {
2512            std::fs::metadata(format!("/proc/self/fd/{fd}"))
2513                .map(|metadata| std::os::unix::fs::MetadataExt::ino(&metadata))
2514                .unwrap()
2515        };
2516        assert_eq!(inode(first.raw_fds().1), inode(second.raw_fds().1));
2517        let host_netns = std::fs::metadata("/proc/self/ns/net")
2518            .map(|metadata| std::os::unix::fs::MetadataExt::ino(&metadata))
2519            .unwrap();
2520        assert_ne!(
2521            inode(first.raw_fds().1),
2522            host_netns,
2523            "the session's namespace is not the runtime's own"
2524        );
2525
2526        netns.end();
2527        netns.end();
2528        assert!(netns.establish(&[], policy()).is_err());
2529        assert_eq!(
2530            netns.creations(),
2531            1,
2532            "an ended namespace is never recreated"
2533        );
2534    }
2535
2536    /// The creator's failure record survives the round trip, so the parent names the step and
2537    /// the errno rather than a bare exit status.
2538    #[cfg(target_os = "linux")]
2539    #[test]
2540    fn a_creation_failure_names_its_step_and_errno() {
2541        assert_eq!(
2542            linux::decode_failure_record(4, libc::EPERM),
2543            Some(NamespaceAttemptError::Step {
2544                step: CreationStep::LocalRoute,
2545                errno: libc::EPERM,
2546            })
2547        );
2548        assert_eq!(linux::decode_failure_record(99, 0), None);
2549    }
2550
2551    /// A seccomp program that makes `close_range` fail with `ENOSYS`, as a kernel before 5.9 or
2552    /// a container profile that does not know the call does, and allows every other syscall.
2553    #[cfg(target_os = "linux")]
2554    fn close_range_enosys_filter() -> [libc::sock_filter; 7] {
2555        // `BPF_LD | BPF_W | BPF_ABS`, `BPF_JMP | BPF_JEQ | BPF_K` and `BPF_RET | BPF_K`.
2556        const BPF_LD_W_ABS: u16 = 0x20;
2557        const BPF_JMP_JEQ_K: u16 = 0x15;
2558        const BPF_RET_K: u16 = 0x06;
2559        const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
2560        const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
2561        // `struct seccomp_data`: `nr` at 0, `arch` at 4.
2562        const NR: u32 = 0;
2563        const ARCH: u32 = 4;
2564        #[cfg(target_arch = "x86_64")]
2565        const AUDIT_ARCH: u32 = 0xc000_003e;
2566        #[cfg(target_arch = "aarch64")]
2567        const AUDIT_ARCH: u32 = 0xc000_00b7;
2568        let statement = |code: u16, jt: u8, jf: u8, k: u32| libc::sock_filter { code, jt, jf, k };
2569        [
2570            statement(BPF_LD_W_ABS, 0, 0, ARCH),
2571            statement(BPF_JMP_JEQ_K, 1, 0, AUDIT_ARCH),
2572            statement(BPF_RET_K, 0, 0, SECCOMP_RET_ALLOW),
2573            statement(BPF_LD_W_ABS, 0, 0, NR),
2574            statement(BPF_JMP_JEQ_K, 0, 1, libc::SYS_close_range as u32),
2575            statement(BPF_RET_K, 0, 0, SECCOMP_RET_ERRNO | libc::ENOSYS as u32),
2576            statement(BPF_RET_K, 0, 0, SECCOMP_RET_ALLOW),
2577        ]
2578    }
2579
2580    /// Where `close_range` is refused, the holder still ends with its session — at
2581    /// [`SessionNetns::end`] and at a drop without it — because the creator closed the
2582    /// lifeline's write end before forking it and the holder closes every other descriptor one
2583    /// at a time. A holder that kept the write end would wait on itself forever.
2584    ///
2585    /// The filter is installed on a thread of its own: a seccomp filter binds the thread that
2586    /// installs it and every process forked from it, so the creator and the holder inherit it
2587    /// and the rest of the test process does not.
2588    #[cfg(target_os = "linux")]
2589    #[test]
2590    #[allow(unsafe_code)]
2591    fn the_holder_ends_with_its_session_where_close_range_is_refused() {
2592        let test = "the_holder_ends_with_its_session_where_close_range_is_refused";
2593        if skip_without_egress_namespace(test) {
2594            return;
2595        }
2596        std::thread::spawn(|| {
2597            let filter = close_range_enosys_filter();
2598            let program = libc::sock_fprog {
2599                len: filter.len() as u16,
2600                filter: filter.as_ptr().cast_mut(),
2601            };
2602            // SAFETY: two `prctl` calls on this thread; `program` points at `filter`, which
2603            // outlives both, and the kernel copies the program when it is installed.
2604            unsafe {
2605                assert_eq!(libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0), 0);
2606                assert_eq!(
2607                    libc::prctl(
2608                        libc::PR_SET_SECCOMP,
2609                        libc::SECCOMP_MODE_FILTER,
2610                        &raw const program,
2611                    ),
2612                    0,
2613                    "installing the filter: {}",
2614                    std::io::Error::last_os_error()
2615                );
2616                // SAFETY: integer arguments only.
2617                let refused = libc::syscall(
2618                    libc::SYS_close_range,
2619                    u32::MAX - 1,
2620                    u32::MAX,
2621                    0 as libc::c_uint,
2622                );
2623                assert_eq!(refused, -1);
2624                assert_eq!(
2625                    std::io::Error::last_os_error().raw_os_error(),
2626                    Some(libc::ENOSYS)
2627                );
2628            }
2629
2630            let policy = || crate::egress_proxy::EgressPolicy::new(Vec::new(), Vec::new());
2631            let end_explicitly = |explicit: bool| {
2632                let netns = SessionNetns::new();
2633                let join = netns
2634                    .establish(&[], policy())
2635                    .expect("the namespace is created");
2636                // This session's own PID namespace, not the first one the test process holds:
2637                // tests running beside this one hold sessions of their own, whose holders are
2638                // pid 1 too and are not under this thread's filter.
2639                let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
2640                    .expect("the session's PID namespace handle is readable");
2641                let holder = std::fs::read_dir("/proc")
2642                    .expect("/proc is readable")
2643                    .filter_map(Result::ok)
2644                    .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
2645                    .find(|pid| {
2646                        std::fs::read_link(format!("/proc/{pid}/ns/pid"))
2647                            .is_ok_and(|namespace| namespace == pid_ns)
2648                            && std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
2649                                |status| {
2650                                    status
2651                                        .lines()
2652                                        .find_map(|line| line.strip_prefix("NSpid:"))
2653                                        .and_then(|fields| fields.split_whitespace().last())
2654                                        == Some("1")
2655                                },
2656                            )
2657                    })
2658                    .expect("the session's holder is pid 1 of its PID namespace");
2659                let status = std::fs::read_to_string(format!("/proc/{holder}/status")).unwrap();
2660                assert!(
2661                    status.lines().any(|line| line == "Seccomp:\t2"),
2662                    "the holder is not under the filter: {status}"
2663                );
2664                // Its descriptor hygiene is done once it blocks in `read` on the lifeline. The
2665                // descriptor table empties long before that: the fallback calls `close` on every
2666                // number up to the open-files limit, which nextest raises to about a million, and
2667                // a session ended mid-loop is only noticed once the loop finishes. Past the last
2668                // open descriptor those calls never sleep, so a holder holding one descriptor and
2669                // sleeping is in that `read`.
2670                let held_fds = || -> Vec<_> {
2671                    std::fs::read_dir(format!("/proc/{holder}/fd"))
2672                        .expect("the holder's descriptor table is readable")
2673                        .filter_map(|entry| std::fs::read_link(entry.ok()?.path()).ok())
2674                        .collect()
2675                };
2676                let sleeping = || {
2677                    std::fs::read_to_string(format!("/proc/{holder}/stat")).is_ok_and(|stat| {
2678                        stat.rsplit_once(") ")
2679                            .is_some_and(|(_, rest)| rest.starts_with('S'))
2680                    })
2681                };
2682                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
2683                let held = loop {
2684                    let held = held_fds();
2685                    if (held.len() <= 1 && sleeping()) || std::time::Instant::now() >= deadline {
2686                        break held;
2687                    }
2688                    std::thread::sleep(std::time::Duration::from_millis(20));
2689                };
2690                assert!(sleeping(), "holder {holder} never blocked on its lifeline");
2691                assert_eq!(
2692                    held.len(),
2693                    1,
2694                    "the holder keeps only its lifeline: {held:?}"
2695                );
2696                assert!(
2697                    held[0].to_string_lossy().starts_with("pipe:"),
2698                    "the holder keeps only its lifeline: {held:?}"
2699                );
2700                if explicit {
2701                    netns.end();
2702                } else {
2703                    drop(netns);
2704                }
2705                drop(join);
2706                // A holder that exited stays in `/proc` as a zombie until its reaper collects it;
2707                // only a holder still running fails.
2708                let running = |pid: i32| {
2709                    std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| {
2710                        stat.rsplit_once(") ")
2711                            .is_some_and(|(_, rest)| !rest.starts_with('Z'))
2712                    })
2713                };
2714                let wait = crate::sandbox::SESSION_END_KILL_WAIT;
2715                let deadline = std::time::Instant::now() + wait;
2716                while running(holder) {
2717                    assert!(
2718                        std::time::Instant::now() < deadline,
2719                        "holder {holder} outlived its session by {wait:?} (ended explicitly: \
2720                         {explicit})"
2721                    );
2722                    std::thread::sleep(std::time::Duration::from_millis(20));
2723                }
2724            };
2725            end_explicitly(true);
2726            end_explicitly(false);
2727        })
2728        .join()
2729        .expect("the filtered thread's assertions hold");
2730    }
2731
2732    /// The probe is the launch's own construction, so on a host where it reports no failure a
2733    /// session creates its namespace, and on one where it reports a failure the session's
2734    /// creation fails at the same step.
2735    #[cfg(target_os = "linux")]
2736    #[test]
2737    fn the_probe_and_a_session_creation_agree_about_this_host() {
2738        let probe = detect_egress_namespace_failure();
2739        let session = linux::create_session_netns(&CapsuleNetnsPlan::resolve(Vec::new())).map(drop);
2740        match (&probe, &session) {
2741            (None, Ok(())) => {}
2742            (Some(failure), Err(NamespaceAttemptError::Step { step, errno })) => {
2743                assert_eq!(failure.failed_call, step.describe());
2744                assert_eq!(failure.errno, Some(*errno));
2745            }
2746            other => panic!("the probe and a session creation disagree: {other:?}"),
2747        }
2748    }
2749}