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