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 native 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 two
20//! namespaces in one `SCM_RIGHTS` message and then exits. The namespace outlives the creator
21//! because the runtime holds those descriptors. Every command then enters it with
22//! [`join_session_netns`] — `setns(2)` into the user namespace, then into the network namespace —
23//! in its own `pre_exec` window, where it is single-threaded and still unfiltered. The runtime's
24//! own threads never call `setns` or `unshare`. The namespace is released when the session ends:
25//! [`SessionNetns::end`] stops the session's egress proxy and closes the descriptors, after
26//! `ShellEnforcement::end_session` has killed every process the session started.
27//!
28//! ## The syscall sequence
29//!
30//! Everything happens in the creator child, before any capsule code runs, and the join happens in
31//! each command's forked child before the seccomp filter (which denies `unshare`, `setns` and
32//! `socket(AF_NETLINK)` outright) and before [`crate::sealed`]'s composed root:
33//!
34//!   1. `unshare(CLONE_NEWUSER | CLONE_NEWNET)`, then the identity `uid_map`/`gid_map` writes that
35//!      make the child the owner of both. The user namespace is not incidental: `CLONE_NEWNET`
36//!      alone needs `CAP_NET_ADMIN`, which an unprivileged process does not have, and creating it
37//!      inside a user namespace it owns is what supplies that capability *there and nowhere else*.
38//!      This is the same primitive `sealed` uses and the same AppArmor restriction governs it —
39//!      see [`EgressNamespaceBlocker`].
40//!   2. `lo` is brought up ([`bring_loopback_up`]). It is the namespace's only interface: there is
41//!      no veth pair, no bridge and no NAT, so nothing in the namespace has a path to the host's
42//!      network or to the outside world.
43//!   3. One route is installed over netlink ([`add_local_default_route`]): `local default dev lo`.
44//!      A `local`-type default route makes every address *locally deliverable*, so a `connect()`
45//!      to any address is looped back to a socket in this namespace with the original destination
46//!      intact — recoverable with `getsockname(2)`. That is the entire interception mechanism,
47//!      and it is why no netfilter rule is needed anywhere. It also means a lookup reaches the
48//!      resolver whatever address `/etc/resolv.conf` names, and a connection to a port nothing is
49//!      bound to is refused immediately rather than stalling through the kernel's full SYN-retry
50//!      schedule against a blackhole.
51//!   4. The listening sockets are created here, *inside* the namespace — one TCP listener per
52//!      port the allowlist implies, then one wildcard UDP :53 resolver socket — and handed back
53//!      to the parent in a single `SCM_RIGHTS` message, followed by the `/proc/self/ns/user` and
54//!      `/proc/self/ns/net` descriptors later commands join through. A socket belongs to the
55//!      namespace it was **created** in, not the one it is used from, which is the whole reason
56//!      this inversion works: the runtime serves them while staying in the host's namespace, with
57//!      real internet access for the upstream half. The listeners are held for the whole session,
58//!      so a server a command starts on one of those ports gets `EADDRINUSE`.
59//!
60//! ## Why this shape rather than the obvious ones
61//!
62//! *No veth pair.* Wiring a namespace to the host with veth requires `CAP_NET_ADMIN` in the
63//! **host's** network namespace, which means real root. This design needs no host privilege at
64//! all, which is what lets `mur` install per-user.
65//!
66//! *No proxy process inside the namespace, and no holder process.* A multithreaded process cannot
67//! enter a user namespace: `setns(user_fd, CLONE_NEWUSER)` from the runtime fails (`EINVAL`, or
68//! `EPERM` from one of its threads). A freshly forked child is single-threaded, and the euid that
69//! owns the session's user namespace holds every capability in it, so the same `setns` from a
70//! command's `pre_exec` window succeeds, followed by `setns(net_fd, CLONE_NEWNET)`. Passing the
71//! listening sockets *out* is what lets the proxy serve the namespace from the host's side with no
72//! privilege at all.
73//!
74//! A long-lived single-threaded holder that forked each command on request was the alternative.
75//! It would make every command its child rather than the runtime's, so every `Child` the shell
76//! path keys on would need a proxy layer, and it would re-implement `std::process::Command`'s spawn
77//! over a request protocol. Joining in `pre_exec` changes one step of the existing spawn instead.
78//!
79//! *No `iptables`/`nft`/`TPROXY`.* Nothing here needs a netfilter rule, so there is no dependency
80//! on netfilter modules being present, no `iptables` binary to shell out to, and no rule set that
81//! can drift out of step with the manifest.
82//!
83//! ## What this does not isolate
84//!
85//! `AF_UNIX` is untouched. `CLONE_NEWNET` does not mediate unix sockets in any way that matters
86//! here — a pathname socket is reached through the filesystem, not the network stack. That is
87//! exactly why the register-level `socket(2)` domain filter (`denied_socket_domains` in
88//! [`crate::sandbox`]) is untouched here and remains the thing that refuses
89//! `/var/run/docker.sock`.
90
91// The probe outcome and the namespace plan stay defined everywhere so the decisions over them are
92// unit-tested on every OS, but outside tests only Linux code builds or reads them, so off Linux
93// they are dead by construction.
94#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
95
96/// One TCP listener per allowlisted port, plus the single wildcard UDP :53 resolver socket.
97pub(crate) const MAX_NAMESPACE_SOCKETS: usize = crate::egress_proxy::MAX_EGRESS_TCP_PORTS + 1;
98
99/// Every descriptor the creator child sends: the namespace sockets, then the user and network
100/// namespace handles.
101///
102/// Bounds the one `SCM_RIGHTS` message, and therefore the fixed stack buffers the creator builds
103/// it in.
104pub(crate) const MAX_SESSION_NETNS_FDS: usize = MAX_NAMESPACE_SOCKETS + 2;
105
106// ---------------------------------------------------------------- blockers
107
108/// Why this host cannot give a capsule its own network namespace.
109///
110/// Exactly two variants, because they are the two things an operator can act on and their
111/// remediations do not overlap: one is a permission this host withholds from the `mur` binary,
112/// the other is a kernel that does not implement the mechanism at all. Collapsing them would send
113/// half the readers to the wrong fix.
114///
115/// Deliberately **not** an `EnforcementTier` variant and deliberately not tied to
116/// `ContainmentClass`: a network namespace is required for every Linux capsule that can spawn a
117/// subprocess, at every containment class, so hanging it off the class ladder would make
118/// `advisory` capsules silently exempt from this boundary.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
120#[serde(rename_all = "snake_case")]
121pub enum EgressNamespaceBlocker {
122    /// The kernel implements unprivileged user namespaces, but this host withholds them from this
123    /// binary: AppArmor's `restrict_unprivileged_userns` is on and the shipped profile is not
124    /// confining `mur`, or `unshare` was refused outright (the container case), or the namespace
125    /// was created and could not then be owned or configured.
126    CapabilityGrantMissing,
127    /// The kernel does not provide the mechanism at all — `CONFIG_USER_NS=n`, or
128    /// `user.max_user_namespaces=0`.
129    KernelSupportMissing,
130}
131
132impl EgressNamespaceBlocker {
133    /// One sentence naming the missing mechanism, plus the exact remediation. Rendered into
134    /// [`crate::errors::RuntimeError::EgressNamespaceUnavailable`], so this is the text an
135    /// operator sees under `E-CAP-005`.
136    #[must_use]
137    pub fn reason(self) -> String {
138        match self {
139            EgressNamespaceBlocker::CapabilityGrantMissing => format!(
140                "this host refused unshare(CLONE_NEWUSER | CLONE_NEWNET) to the mur binary. On an \
141                 AppArmor host (Ubuntu 23.10+ and derivatives) this is the unprivileged-userns \
142                 restriction: install and load the profile shipped with mur, `sudo install -m 644 \
143                 packaging/apparmor/{name} {path} && sudo apparmor_parser -r {path}` (or re-run \
144                 the mur installer as root), then re-run. Building out of a checkout, where the \
145                 binary sits at ./target/{{debug,release}}/mur and no shipped profile attaches to \
146                 it: run `scripts/install-dev-apparmor.sh`, which generates and loads the same \
147                 grant for those two paths. Inside a container it is a missing capability \
148                 instead: add `--cap-add SYS_ADMIN` to the container invocation, or create the \
149                 network namespace outside the container and run mur inside it. LAST RESORT, only \
150                 where a profile genuinely cannot be loaded: `sudo sysctl -w \
151                 kernel.apparmor_restrict_unprivileged_userns=0` — this removes \
152                 unprivileged-userns hardening from every program on the machine, not just from \
153                 mur, and is not the configuration murmur ships.",
154                name = crate::sealed::SEALED_APPARMOR_PROFILE_NAME,
155                path = crate::sealed::SEALED_APPARMOR_PROFILE_PATH,
156            ),
157            EgressNamespaceBlocker::KernelSupportMissing => {
158                "this kernel does not provide unprivileged user namespaces, which a capsule's \
159                 network namespace has to be created inside (CONFIG_USER_NS=n, or \
160                 user.max_user_namespaces=0). Raise `sudo sysctl -w \
161                 user.max_user_namespaces=10000` if the sysctl is merely zeroed, otherwise run on \
162                 a kernel built with CONFIG_USER_NS=y."
163                    .to_string()
164            }
165        }
166    }
167}
168
169// ---------------------------------------------------------------- probe
170
171/// What a real `unshare(CLONE_NEWUSER | CLONE_NEWNET)` attempt in a forked child did.
172///
173/// Richer than the two-valued [`EgressNamespaceBlocker`] on purpose: the probe records the step
174/// that failed, and the mapping to a blocker is then an explicit, reviewable, unit-tested
175/// decision rather than something the probe pre-decided in the dark.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
177pub(crate) enum EgressNamespaceProbe {
178    /// The namespace was created, owned, and configured — `lo` came up and the local route
179    /// installed. Everything the real launch does, rehearsed.
180    Ok,
181    /// `unshare` was refused (`EPERM`).
182    Denied,
183    /// The namespace was created but writing the identity `uid_map`/`gid_map` was refused.
184    MapDenied,
185    /// Created and owned, but configuring it failed. Kept apart from `Denied` because it is the
186    /// signature of a confinement that permits namespace creation and then withholds
187    /// `CAP_NET_ADMIN` inside it.
188    ConfigDenied,
189    /// The kernel does not implement it, or the probe could not run at all.
190    #[default]
191    Unsupported,
192}
193
194/// Everything the host probe learned, kept separate from the decision that uses it so
195/// [`egress_namespace_blocker`] stays pure.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
197pub(crate) struct EgressNamespaceSupport {
198    /// Where this host's permission for an unprivileged user namespace comes from.
199    ///
200    /// Answered by `sealed`'s own probe of exactly the same question: the restriction is on
201    /// `CLONE_NEWUSER`, which both mechanisms need, so asking it twice with two implementations
202    /// could only ever produce two answers that disagree about one host.
203    pub(crate) userns_grant: crate::sealed::UsernsGrant,
204    pub(crate) namespace: EgressNamespaceProbe,
205}
206
207/// Which of the two blockers to name, given the probe. Pure, and unit-testable on any OS.
208///
209/// `None` means this host can give a capsule its own network namespace.
210pub(crate) fn egress_namespace_blocker(
211    is_linux: bool,
212    support: EgressNamespaceSupport,
213) -> Option<EgressNamespaceBlocker> {
214    // Not Linux: there is no network namespace here, and there was no seccomp interception
215    // either. A non-Linux host resolves to `EnforcementTier::EnvironmentOnly`, installs no kernel
216    // subprocess sandbox at all, and says so loudly (`W_SEC_001`). Refusing here would turn every
217    // macOS run into an error about a mechanism macOS has never had.
218    if !is_linux {
219        return None;
220    }
221    // AppArmor before the namespace outcome, for the reason `sealed_blocker` documents: when the
222    // restriction is on and our profile is absent, the `unshare` failure is a *consequence*, and
223    // blaming the kernel would send an Ubuntu desktop user somewhere useless.
224    if !support.userns_grant.permits_userns() {
225        return Some(EgressNamespaceBlocker::CapabilityGrantMissing);
226    }
227    match support.namespace {
228        EgressNamespaceProbe::Ok => None,
229        EgressNamespaceProbe::Denied
230        | EgressNamespaceProbe::MapDenied
231        | EgressNamespaceProbe::ConfigDenied => {
232            Some(EgressNamespaceBlocker::CapabilityGrantMissing)
233        }
234        EgressNamespaceProbe::Unsupported => Some(EgressNamespaceBlocker::KernelSupportMissing),
235    }
236}
237
238/// Which blocker, if any, stands between this host and a capsule network namespace.
239///
240/// Reads a process-cached probe, so the refusal an operator sees at staging time and the
241/// mechanism a launch actually installs can never disagree about the host.
242#[cfg(target_os = "linux")]
243#[must_use]
244pub fn detect_egress_namespace_blocker() -> Option<EgressNamespaceBlocker> {
245    static SUPPORT: std::sync::OnceLock<EgressNamespaceSupport> = std::sync::OnceLock::new();
246    let support = *SUPPORT.get_or_init(linux::probe_egress_namespace);
247    egress_namespace_blocker(true, support)
248}
249
250#[cfg(not(target_os = "linux"))]
251#[must_use]
252pub fn detect_egress_namespace_blocker() -> Option<EgressNamespaceBlocker> {
253    egress_namespace_blocker(false, EgressNamespaceSupport::default())
254}
255
256/// Whether a test that needs a capsule network namespace must stand down on this host, printing
257/// the blocker when it must.
258///
259/// Test support, not a runtime code path. Asked through [`detect_egress_namespace_blocker`], the
260/// same probe `stage_session` consults, so the gate and the staging refusal cannot reach different
261/// conclusions about the host.
262///
263/// Narrower than [`crate::skip_without_host_support`], which also requires a delegated cgroup
264/// scope: a test driving `execute_shell` directly needs the namespace and nothing else, and
265/// standing down on the cgroup half would skip it on hosts where it runs perfectly well.
266pub fn skip_without_egress_namespace(test_name: &str) -> bool {
267    match detect_egress_namespace_blocker() {
268        Some(blocker) => {
269            crate::runtime_err!("[SKIP-HOST] {test_name}: {}", blocker.reason());
270            true
271        }
272        None => false,
273    }
274}
275
276/// Refuses the launch when a capsule that can spawn native subprocesses is on a host that cannot
277/// give them a network namespace.
278///
279/// Pure — the host answer is passed in — so the decision is testable on any OS at any tier.
280///
281/// `can_spawn_subprocess` gates the whole check rather than
282/// `capabilities.network.allow` being non-empty, and the asymmetry is deliberate: an empty
283/// allowlist means *no* egress, and without the namespace to deliver that, the capsule would get
284/// unrestricted egress instead. Refusing only capsules that asked for some network would let
285/// exactly the capsules that asked for none run with the most.
286pub fn check_egress_namespace(
287    can_spawn_subprocess: bool,
288    blocker: Option<EgressNamespaceBlocker>,
289) -> Result<(), crate::errors::RuntimeError> {
290    if !can_spawn_subprocess {
291        return Ok(());
292    }
293    match blocker {
294        None => Ok(()),
295        Some(blocker) => Err(crate::errors::RuntimeError::EgressNamespaceUnavailable {
296            blocker,
297            reason: blocker.reason(),
298        }),
299    }
300}
301
302// ---------------------------------------------------------------- the plan
303
304/// The fully-resolved, allocation-free description of the namespace the forked child will build.
305///
306/// Precomputed in the **parent**, before `fork()`, for the reason [`crate::sealed`] documents at
307/// length: the `pre_exec` window permits only async-signal-safe work, and allocating there can
308/// deadlock on an allocator lock a different thread of the parent held at `fork()` time. The
309/// child reads this and issues syscalls; it computes nothing and allocates nothing.
310#[derive(Debug, Clone)]
311pub(crate) struct CapsuleNetnsPlan {
312    /// TCP ports to bind and listen on inside the namespace, ascending — exactly
313    /// `egress_proxy::egress_listen_ports` for this capsule's allowlist.
314    ///
315    /// May be empty, which is a capsule with no TCP egress at all. The UDP :53 socket is bound
316    /// regardless, so even that capsule gets `REFUSED` *answers* to its lookups rather than the
317    /// multi-second resolver stall a dropped packet produces.
318    pub(crate) tcp_ports: Vec<u16>,
319    /// The real uid/gid, read in the parent.
320    ///
321    /// Load-bearing ordering, exactly as in `sealed::probe_namespace`: inside a fresh user
322    /// namespace with no map written yet, `getuid()` reports the overflow id (65534), and writing
323    /// that into `uid_map` is refused on every host.
324    pub(crate) uid: u32,
325    pub(crate) gid: u32,
326}
327
328impl CapsuleNetnsPlan {
329    /// Resolves the plan for one session's namespace.
330    #[cfg(target_os = "linux")]
331    #[allow(unsafe_code)]
332    pub(crate) fn resolve(tcp_ports: Vec<u16>) -> Self {
333        // SAFETY: `getuid`/`getgid` take no arguments, dereference nothing and cannot fail.
334        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
335        Self {
336            tcp_ports,
337            uid,
338            gid,
339        }
340    }
341
342    /// How many descriptors the child will send: one per TCP port, plus the resolver socket.
343    pub(crate) fn socket_count(&self) -> usize {
344        self.tcp_ports.len() + 1
345    }
346}
347
348// ---------------------------------------------------------------- the session's namespace
349
350/// One session's network namespace, created on first use and shared by every command after it.
351///
352/// Always held as `Arc<SessionNetns>` on [`crate::sandbox::ShellEnforcement`], so every clone of a
353/// session's enforcement — each tool call's, each demoted command's, a plan's — reaches the same
354/// namespace. Its state moves one way: not yet created, established, ended. An ended namespace is
355/// never recreated, so a command spawned after its session ended is refused rather than given a
356/// fresh namespace nobody will tear down.
357///
358/// Off Linux there is no namespace to create: the type exists so the session-end path needs no
359/// `cfg`, and it only ever moves from not yet created to ended.
360#[derive(Debug, Default)]
361pub(crate) struct SessionNetns {
362    state: std::sync::Mutex<SessionNetnsState>,
363    /// Successful creations, which a test compares against the commands it ran.
364    #[cfg(test)]
365    creations: std::sync::atomic::AtomicUsize,
366}
367
368#[derive(Debug, Default)]
369enum SessionNetnsState {
370    #[default]
371    NotYetCreated,
372    #[cfg(target_os = "linux")]
373    Established(linux::EstablishedNetns),
374    Ended,
375}
376
377/// What a command's `pre_exec` window needs to join its session's namespace: the user and network
378/// namespace descriptors.
379///
380/// Shares the session's descriptors rather than duplicating them, so they stay open for as long as
381/// the `Command` holding this does, even if the session ends between `establish` and the spawn: a
382/// join then enters a namespace whose processes are being killed, never a reused descriptor number.
383#[cfg(target_os = "linux")]
384#[derive(Debug, Clone)]
385pub(crate) struct SessionNetnsJoin {
386    user_ns: std::sync::Arc<std::os::fd::OwnedFd>,
387    net_ns: std::sync::Arc<std::os::fd::OwnedFd>,
388}
389
390#[cfg(target_os = "linux")]
391impl SessionNetnsJoin {
392    /// `(user_ns, net_ns)`, for [`join_session_netns`].
393    pub(crate) fn raw_fds(&self) -> (std::os::fd::RawFd, std::os::fd::RawFd) {
394        use std::os::fd::AsRawFd;
395        (self.user_ns.as_raw_fd(), self.net_ns.as_raw_fd())
396    }
397}
398
399impl SessionNetns {
400    /// A session whose namespace does not exist yet. Creates nothing.
401    pub(crate) fn new() -> Self {
402        Self::default()
403    }
404
405    /// The handles a command joins the session's namespace through, creating the namespace and
406    /// starting the session's egress proxy on the first call.
407    ///
408    /// Serialised on one mutex, so concurrent first commands create exactly one namespace. A
409    /// failed creation leaves the namespace not yet created and leaks nothing, so that command's
410    /// spawn fails and the next one tries again. `tcp_ports` and `policy` are read on the call
411    /// that creates the namespace; every later call is handed the namespace that call created.
412    #[cfg(target_os = "linux")]
413    pub(crate) fn establish(
414        &self,
415        tcp_ports: &[u16],
416        policy: crate::egress_proxy::EgressPolicy,
417    ) -> Result<SessionNetnsJoin, String> {
418        let mut state = self
419            .state
420            .lock()
421            .unwrap_or_else(std::sync::PoisonError::into_inner);
422        match &*state {
423            SessionNetnsState::Established(established) => return Ok(established.join()),
424            SessionNetnsState::Ended => {
425                return Err(
426                    "sandbox: this session has ended, and its network namespace was released \
427                     with it; no further command runs in it"
428                        .to_string(),
429                )
430            }
431            SessionNetnsState::NotYetCreated => {}
432        }
433        let plan = CapsuleNetnsPlan::resolve(tcp_ports.to_vec());
434        let established = linux::create_session_netns(&plan)
435            .and_then(|created| created.serve(policy))
436            .map_err(|detail| {
437                format!("sandbox: could not create this session's network namespace: {detail}")
438            })?;
439        let join = established.join();
440        *state = SessionNetnsState::Established(established);
441        #[cfg(test)]
442        self.creations
443            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
444        Ok(join)
445    }
446
447    /// Releases the namespace: stops the session's egress proxy and closes the namespace
448    /// descriptors. Idempotent, and final — [`Self::establish`] refuses every call after it.
449    ///
450    /// The namespace itself is gone once its last process is: the caller kills the session's
451    /// processes first (`ShellEnforcement::end_session`).
452    pub(crate) fn end(&self) {
453        let previous = std::mem::replace(
454            &mut *self
455                .state
456                .lock()
457                .unwrap_or_else(std::sync::PoisonError::into_inner),
458            SessionNetnsState::Ended,
459        );
460        #[cfg(target_os = "linux")]
461        if let SessionNetnsState::Established(established) = previous {
462            established.release();
463        }
464        #[cfg(not(target_os = "linux"))]
465        drop(previous);
466    }
467
468    /// How many namespaces this session has created: `0` before its first command, `1` after.
469    #[cfg(test)]
470    pub(crate) fn creations(&self) -> usize {
471        self.creations.load(std::sync::atomic::Ordering::SeqCst)
472    }
473}
474
475impl Drop for SessionNetns {
476    /// The backstop for a session that never reached `end_session`: the proxy stops and the
477    /// descriptors close when the last enforcement holding this goes.
478    fn drop(&mut self) {
479        self.end();
480    }
481}
482
483#[cfg(target_os = "linux")]
484pub(crate) use linux::join_session_netns;
485
486#[cfg(target_os = "linux")]
487#[allow(unsafe_code)]
488mod linux {
489    use std::io;
490    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
491    use std::sync::Arc;
492
493    use super::{
494        CapsuleNetnsPlan, EgressNamespaceProbe, EgressNamespaceSupport, SessionNetnsJoin,
495        MAX_NAMESPACE_SOCKETS, MAX_SESSION_NETNS_FDS,
496    };
497
498    // ------------------------------------------------------------ netlink constants
499    //
500    // Spelled out here rather than taken from `libc`, whose rtnetlink coverage varies by target
501    // and libc flavour. These are uapi ABI constants — fixed for the lifetime of the interface —
502    // and a wrong one fails loudly at the netlink ACK rather than silently.
503
504    const RTM_NEWROUTE: u16 = 24;
505    const NLM_F_REQUEST: u16 = 0x001;
506    const NLM_F_ACK: u16 = 0x004;
507    const NLM_F_EXCL: u16 = 0x200;
508    const NLM_F_CREATE: u16 = 0x400;
509    const NLMSG_ERROR: u16 = 2;
510    /// Table 255, `local` — the table whose routes cause *input* delivery. A default route here
511    /// is what makes every address locally deliverable.
512    const RT_TABLE_LOCAL: u8 = 255;
513    const RTPROT_BOOT: u8 = 3;
514    const RT_SCOPE_HOST: u8 = 254;
515    const RTN_LOCAL: u8 = 2;
516    const RTA_OIF: u16 = 4;
517    /// `lo` is always interface index 1 in a freshly created network namespace: it is the only
518    /// interface there, and the kernel creates it first.
519    const LOOPBACK_IFINDEX: u32 = 1;
520
521    const NLMSGHDR_LEN: usize = 16;
522    const RTMSG_LEN: usize = 12;
523    const RTATTR_OIF_LEN: usize = 8;
524    const ROUTE_REQUEST_LEN: usize = NLMSGHDR_LEN + RTMSG_LEN + RTATTR_OIF_LEN;
525
526    // ------------------------------------------------------------ probe
527
528    /// Rehearses, in a forked child, exactly what a real launch does: create the namespace, own
529    /// it, bring `lo` up and install the local route.
530    ///
531    /// Forking rather than testing in-process is required, not defensive — `unshare(CLONE_NEWUSER)`
532    /// is irreversible for the calling process, and putting the whole runtime into a user
533    /// namespace as a side effect of a capability check is the "probe that changes the thing it
534    /// measures" mistake `sealed::probe_namespace` documents avoiding.
535    pub(super) fn probe_egress_namespace() -> EgressNamespaceSupport {
536        EgressNamespaceSupport {
537            userns_grant: crate::sealed::userns_grant(),
538            namespace: probe_namespace(),
539        }
540    }
541
542    // Exit codes the probe child reports, so the parent learns the failing *step* with no IPC
543    // beyond `waitpid`. Same convention as `sealed::probe_namespace`.
544    const PROBE_OK: i32 = 0;
545    const PROBE_UNSHARE_DENIED: i32 = 1;
546    const PROBE_UNSHARE_UNSUPPORTED: i32 = 2;
547    const PROBE_MAP_DENIED: i32 = 3;
548    const PROBE_CONFIG_DENIED: i32 = 4;
549
550    fn probe_namespace() -> EgressNamespaceProbe {
551        // Read before the fork, and therefore before `unshare`. See `CapsuleNetnsPlan::uid`.
552        // SAFETY: `getuid`/`getgid` take no arguments and cannot fail.
553        let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
554
555        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
556        // touches nothing but async-signal-safe primitives. The child below calls `unshare`,
557        // `prctl`, the `open`/`write`/`close` triples that write the identity maps, `socket`,
558        // `ioctl`, `sendto`/`recv` and `_exit` — every one async-signal-safe, with no allocation,
559        // no locks and no stdio.
560        let pid = unsafe { libc::fork() };
561        if pid < 0 {
562            return EgressNamespaceProbe::Unsupported;
563        }
564        if pid == 0 {
565            // SAFETY: forked-child context, syscalls only; every branch ends in `_exit`.
566            unsafe {
567                if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
568                    let errno = *libc::__errno_location();
569                    libc::_exit(match errno {
570                        libc::EPERM => PROBE_UNSHARE_DENIED,
571                        _ => PROBE_UNSHARE_UNSUPPORTED,
572                    });
573                }
574                if write_identity_maps(uid, gid).is_err() {
575                    libc::_exit(PROBE_MAP_DENIED);
576                }
577                if bring_loopback_up().is_err() || add_local_default_route().is_err() {
578                    libc::_exit(PROBE_CONFIG_DENIED);
579                }
580                libc::_exit(PROBE_OK);
581            }
582        }
583
584        let mut status: libc::c_int = 0;
585        // SAFETY: `pid` is the child just forked; `status` is a live local.
586        let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
587        if waited != pid || !libc::WIFEXITED(status) {
588            return EgressNamespaceProbe::Unsupported;
589        }
590        match libc::WEXITSTATUS(status) {
591            PROBE_OK => EgressNamespaceProbe::Ok,
592            PROBE_UNSHARE_DENIED => EgressNamespaceProbe::Denied,
593            PROBE_MAP_DENIED => EgressNamespaceProbe::MapDenied,
594            PROBE_CONFIG_DENIED => EgressNamespaceProbe::ConfigDenied,
595            _ => EgressNamespaceProbe::Unsupported,
596        }
597    }
598
599    // ------------------------------------------------------------ the creator child
600
601    /// The step of the namespace's construction that failed, as the creator reports it.
602    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
603    pub(super) enum CreationStep {
604        Unshare = 1,
605        IdentityMaps = 2,
606        Loopback = 3,
607        LocalRoute = 4,
608        TcpListener = 5,
609        Resolver = 6,
610        NamespaceHandles = 7,
611        HandOff = 8,
612    }
613
614    impl CreationStep {
615        fn from_code(code: u32) -> Option<Self> {
616            Some(match code {
617                1 => Self::Unshare,
618                2 => Self::IdentityMaps,
619                3 => Self::Loopback,
620                4 => Self::LocalRoute,
621                5 => Self::TcpListener,
622                6 => Self::Resolver,
623                7 => Self::NamespaceHandles,
624                8 => Self::HandOff,
625                _ => return None,
626            })
627        }
628
629        fn describe(self) -> &'static str {
630            match self {
631                Self::Unshare => "unshare(CLONE_NEWUSER | CLONE_NEWNET)",
632                Self::IdentityMaps => "writing uid_map/gid_map",
633                Self::Loopback => "bringing lo up",
634                Self::LocalRoute => "installing the local default route",
635                Self::TcpListener => "binding a TCP listener inside the namespace",
636                Self::Resolver => "binding UDP 53 inside the namespace",
637                Self::NamespaceHandles => "opening /proc/self/ns/user and /proc/self/ns/net",
638                Self::HandOff => "handing the namespace to the runtime",
639            }
640        }
641    }
642
643    /// A failed step and the `errno` it left, carried out of the creator as a fixed-size record.
644    ///
645    /// `Copy` and built from integers only, so the creator reports a failure without allocating.
646    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
647    pub(super) struct CreationFailure {
648        step: CreationStep,
649        errno: i32,
650    }
651
652    /// Bytes in an encoded [`CreationFailure`]: the step's code, then the `errno`.
653    const FAILURE_RECORD_LEN: usize = 8;
654
655    impl CreationFailure {
656        /// The failure of `step`, with the `errno` the failing call just left.
657        ///
658        /// # Safety
659        /// Reads the calling thread's `errno`; async-signal-safe.
660        unsafe fn at(step: CreationStep) -> Self {
661            // SAFETY: `__errno_location` returns the calling thread's own errno slot, which is
662            // always valid to read.
663            let errno = unsafe { *libc::__errno_location() };
664            Self { step, errno }
665        }
666
667        fn encode(self) -> [u8; FAILURE_RECORD_LEN] {
668            let mut record = [0u8; FAILURE_RECORD_LEN];
669            record[..4].copy_from_slice(&(self.step as u32).to_ne_bytes());
670            record[4..].copy_from_slice(&self.errno.to_ne_bytes());
671            record
672        }
673
674        fn decode(record: &[u8; FAILURE_RECORD_LEN]) -> Option<Self> {
675            let step = CreationStep::from_code(u32::from_ne_bytes([
676                record[0], record[1], record[2], record[3],
677            ]))?;
678            let errno = i32::from_ne_bytes([record[4], record[5], record[6], record[7]]);
679            Some(Self { step, errno })
680        }
681    }
682
683    impl std::fmt::Display for CreationFailure {
684        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
685            write!(
686                f,
687                "{} failed: {}",
688                self.step.describe(),
689                io::Error::from_raw_os_error(self.errno)
690            )
691        }
692    }
693
694    /// Builds the session's network namespace and hands it to the parent: its listening sockets,
695    /// then descriptors for its user and network namespaces, in one `SCM_RIGHTS` message ordered
696    /// `[tcp listeners…, udp53, user_ns, net_ns]`.
697    ///
698    /// Runs only in the creator child [`create_session_netns`] forks, which runs no capsule code,
699    /// installs no seccomp filter or Landlock domain and joins no cgroup scope; it `_exit`s as
700    /// soon as this returns. Commands join what this builds through [`join_session_netns`]. A
701    /// `sealed` command goes on to `unshare(CLONE_NEWUSER | CLONE_NEWNS)` a *nested* user
702    /// namespace, and a process in a descendant user namespace holds no capabilities in the
703    /// ancestor that owns this network namespace, so a `sealed` capsule cannot reconfigure the
704    /// namespace confining it: `SIOCSIFFLAGS` returns `EPERM` from inside the nested namespace.
705    ///
706    /// # Safety
707    /// Post-`fork()` child context only. Every call below is async-signal-safe and nothing here
708    /// allocates: `plan` was fully resolved in the parent, and a failure is a `Copy` record.
709    unsafe fn create_capsule_netns(
710        plan: &CapsuleNetnsPlan,
711        sock_fd: RawFd,
712    ) -> Result<(), CreationFailure> {
713        // SAFETY: forked-child context; the caller's `unsafe` contract covers this whole body.
714        unsafe {
715            if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
716                return Err(CreationFailure::at(CreationStep::Unshare));
717            }
718            if write_identity_maps(plan.uid, plan.gid).is_err() {
719                return Err(CreationFailure::at(CreationStep::IdentityMaps));
720            }
721            if bring_loopback_up().is_err() {
722                return Err(CreationFailure::at(CreationStep::Loopback));
723            }
724            if add_local_default_route().is_err() {
725                return Err(CreationFailure::at(CreationStep::LocalRoute));
726            }
727
728            // One TCP listener per allowlisted port, then the resolver socket — the order the
729            // parent's `start_egress_proxy` splits them back apart in — then the two namespace
730            // handles, which the parent takes off the end.
731            let mut fds = [-1 as RawFd; MAX_SESSION_NETNS_FDS];
732            let mut count = 0usize;
733
734            for port in &plan.tcp_ports {
735                let fd = bind_tcp_listener(*port);
736                if fd < 0 {
737                    let failure = CreationFailure::at(CreationStep::TcpListener);
738                    close_all(&fds[..count]);
739                    return Err(failure);
740                }
741                fds[count] = fd;
742                count += 1;
743            }
744
745            let resolver = bind_dns_socket();
746            if resolver < 0 {
747                let failure = CreationFailure::at(CreationStep::Resolver);
748                close_all(&fds[..count]);
749                return Err(failure);
750            }
751            fds[count] = resolver;
752            count += 1;
753
754            // Opened after the maps are written, so both name the namespaces this child now owns.
755            for namespace in [c"/proc/self/ns/user", c"/proc/self/ns/net"] {
756                let fd = libc::open(namespace.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC);
757                if fd < 0 {
758                    let failure = CreationFailure::at(CreationStep::NamespaceHandles);
759                    close_all(&fds[..count]);
760                    return Err(failure);
761                }
762                fds[count] = fd;
763                count += 1;
764            }
765
766            let sent = send_fds(sock_fd, &fds[..count]);
767            let failure = CreationFailure::at(CreationStep::HandOff);
768            close_all(&fds[..count]);
769            sent.map_err(|()| failure)
770        }
771    }
772
773    /// Enters the session's namespaces: `setns(user_fd, CLONE_NEWUSER)`, then
774    /// `setns(net_fd, CLONE_NEWNET)`. Returns the `errno` of whichever failed.
775    ///
776    /// The order is required. Joining a network namespace needs `CAP_SYS_ADMIN` in the user
777    /// namespace that owns it, which the caller holds only once it is inside that user namespace;
778    /// joining the user namespace needs the caller to be single-threaded and its euid to own the
779    /// namespace, which a command's forked child is and does.
780    ///
781    /// # Safety
782    /// Post-`fork()`, pre-exec child context only. Both descriptors must be open; the
783    /// [`SessionNetnsJoin`] they came from keeps them so.
784    pub(crate) unsafe fn join_session_netns(user_fd: RawFd, net_fd: RawFd) -> io::Result<()> {
785        // SAFETY: `setns` takes two integers and dereferences nothing; `last_os_error` reads
786        // errno and does not allocate.
787        unsafe {
788            if libc::setns(user_fd, libc::CLONE_NEWUSER) != 0 {
789                return Err(io::Error::last_os_error());
790            }
791            if libc::setns(net_fd, libc::CLONE_NEWNET) != 0 {
792                return Err(io::Error::last_os_error());
793            }
794        }
795        Ok(())
796    }
797
798    /// What the creator handed over: the namespace sockets, ready for the egress proxy, and the
799    /// two namespace handles.
800    pub(super) struct CreatedNetns {
801        sockets: Vec<OwnedFd>,
802        user_ns: OwnedFd,
803        net_ns: OwnedFd,
804    }
805
806    impl CreatedNetns {
807        /// Starts the session's egress proxy on the namespace sockets.
808        ///
809        /// On failure the sockets and the namespace handles close here, which leaves no process
810        /// and no descriptor holding the namespace.
811        pub(super) fn serve(
812            self,
813            policy: crate::egress_proxy::EgressPolicy,
814        ) -> Result<EstablishedNetns, String> {
815            let proxy = crate::egress_proxy::start_egress_proxy(self.sockets, policy)
816                .map_err(|error| format!("starting the session's egress proxy failed: {error}"))?;
817            Ok(EstablishedNetns {
818                user_ns: Arc::new(self.user_ns),
819                net_ns: Arc::new(self.net_ns),
820                proxy,
821            })
822        }
823    }
824
825    /// An established session namespace: the two handles commands join through, and the proxy
826    /// serving its listeners.
827    #[derive(Debug)]
828    pub(super) struct EstablishedNetns {
829        user_ns: Arc<OwnedFd>,
830        net_ns: Arc<OwnedFd>,
831        proxy: crate::egress_proxy::EgressProxyHandle,
832    }
833
834    impl EstablishedNetns {
835        pub(super) fn join(&self) -> SessionNetnsJoin {
836            SessionNetnsJoin {
837                user_ns: Arc::clone(&self.user_ns),
838                net_ns: Arc::clone(&self.net_ns),
839            }
840        }
841
842        /// Stops the proxy and drops this session's hold on the namespace handles.
843        pub(super) fn release(self) {
844            self.proxy.shutdown();
845        }
846    }
847
848    /// Forks the creator child, waits for it, and takes what it handed over.
849    ///
850    /// The creator is a child of its own rather than a command's `pre_exec` step, so no command is
851    /// special: a command that fails cannot take the session's namespace with it, and creation
852    /// never depends on what any command is.
853    pub(super) fn create_session_netns(plan: &CapsuleNetnsPlan) -> Result<CreatedNetns, String> {
854        // Checked here because the creator indexes a fixed buffer of this size and must not be the
855        // place an oversized plan is discovered.
856        if plan.socket_count() > MAX_NAMESPACE_SOCKETS {
857            return Err(format!(
858                "{} namespace sockets requested, more than the {MAX_NAMESPACE_SOCKETS} one \
859                 hand-off carries",
860                plan.socket_count()
861            ));
862        }
863
864        let mut pair = [0i32; 2];
865        // SAFETY: `pair` is a 2-element stack array, exactly the size `socketpair` writes into.
866        let rc = unsafe {
867            libc::socketpair(
868                libc::AF_UNIX,
869                libc::SOCK_DGRAM | libc::SOCK_CLOEXEC,
870                0,
871                pair.as_mut_ptr(),
872            )
873        };
874        if rc != 0 {
875            return Err(format!(
876                "socketpair() for the namespace hand-off failed: {}",
877                io::Error::last_os_error()
878            ));
879        }
880        // SAFETY: both fds were just returned by the successful `socketpair` above and nothing
881        // else owns them.
882        let (parent_end, creator_end) =
883            unsafe { (OwnedFd::from_raw_fd(pair[0]), OwnedFd::from_raw_fd(pair[1])) };
884        let creator_fd = creator_end.as_raw_fd();
885
886        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
887        // touches nothing but async-signal-safe primitives. The child runs only
888        // `create_capsule_netns` — `unshare`, `prctl`, `open`/`write`/`close`, `socket`, `ioctl`,
889        // `bind`, `listen`, `setsockopt`, `sendto`/`recv`/`sendmsg` — then `send` and `_exit`,
890        // with no allocation, no locks and no stdio.
891        let pid = unsafe { libc::fork() };
892        if pid < 0 {
893            return Err(format!(
894                "fork() for the namespace creator failed: {}",
895                io::Error::last_os_error()
896            ));
897        }
898        if pid == 0 {
899            // SAFETY: forked-child context, syscalls only; both branches end in `_exit`, so no
900            // destructor of the parent's state runs here.
901            unsafe {
902                match create_capsule_netns(plan, creator_fd) {
903                    Ok(()) => libc::_exit(0),
904                    Err(failure) => {
905                        let record = failure.encode();
906                        libc::send(creator_fd, record.as_ptr().cast(), record.len(), 0);
907                        libc::_exit(1);
908                    }
909                }
910            }
911        }
912        drop(creator_end);
913
914        let status = wait_for_creator(pid)?;
915        match receive_session_netns(parent_end.as_raw_fd(), plan.socket_count())? {
916            Received::Handles(mut fds) => {
917                let net_ns = fds.pop().expect("the count was checked on receipt");
918                let user_ns = fds.pop().expect("the count was checked on receipt");
919                Ok(CreatedNetns {
920                    sockets: fds,
921                    user_ns,
922                    net_ns,
923                })
924            }
925            Received::Failure(failure) => Err(failure.to_string()),
926            Received::Nothing => Err(format!(
927                "the namespace creator exited without handing anything over ({status})"
928            )),
929        }
930    }
931
932    /// Reaps the creator. The hand-off is read only afterwards: a `SOCK_DGRAM` pair reports no
933    /// end-of-file when its peer closes, so reading first would block forever on a creator that
934    /// died before sending, while the message a creator did send stays queued after it exits.
935    fn wait_for_creator(pid: libc::pid_t) -> Result<String, String> {
936        let mut status: libc::c_int = 0;
937        loop {
938            // SAFETY: `pid` is the creator just forked; `status` is a live local.
939            let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
940            if waited == pid {
941                break;
942            }
943            let error = io::Error::last_os_error();
944            if error.kind() != io::ErrorKind::Interrupted {
945                return Err(format!("waiting for the namespace creator failed: {error}"));
946            }
947        }
948        Ok(if libc::WIFEXITED(status) {
949            format!("exit status {}", libc::WEXITSTATUS(status))
950        } else if libc::WIFSIGNALED(status) {
951            format!("killed by signal {}", libc::WTERMSIG(status))
952        } else {
953            format!("wait status {status}")
954        })
955    }
956
957    /// Encodes and decodes a failure record as the creator and the parent do, rendered as the
958    /// parent renders it.
959    #[cfg(test)]
960    pub(super) fn describe_failure_record(step: u32, errno: i32) -> Option<String> {
961        let mut record = [0u8; FAILURE_RECORD_LEN];
962        record[..4].copy_from_slice(&step.to_ne_bytes());
963        record[4..].copy_from_slice(&errno.to_ne_bytes());
964        CreationFailure::decode(&record).map(|failure| {
965            assert_eq!(failure.encode(), record);
966            failure.to_string()
967        })
968    }
969
970    /// The creator's one message.
971    enum Received {
972        /// `[tcp listeners…, udp53, user_ns, net_ns]`, every one `CLOEXEC`.
973        Handles(Vec<OwnedFd>),
974        Failure(CreationFailure),
975        Nothing,
976    }
977
978    /// Writes `setgroups=deny` and the identity `uid_map`/`gid_map`, reopening the dumpable
979    /// window around them.
980    ///
981    /// The dumpable dance is not optional and is `sealed`'s hardest-won lesson, reused here
982    /// rather than rediscovered: `mur` marks itself non-dumpable at startup, the flag is inherited
983    /// across `fork()`, and the kernel reassigns every `/proc/<pid>/*` entry of a non-dumpable
984    /// task to root — including `uid_map`. Without this the namespace is created and can never be
985    /// owned, and the failure reads as a host id-mapping policy problem on a host whose policy is
986    /// perfectly fine.
987    ///
988    /// # Safety
989    /// Post-`fork()`, pre-exec child context only.
990    unsafe fn write_identity_maps(uid: u32, gid: u32) -> Result<(), ()> {
991        // SAFETY: caller's contract; every call below is async-signal-safe.
992        unsafe {
993            let previous = crate::sealed::make_dumpable_for_map_writes();
994            let _ = crate::sealed::write_decimal_map(c"/proc/self/setgroups", None, 0);
995            let mapped = crate::sealed::write_decimal_map(c"/proc/self/uid_map", Some(uid), uid)
996                .is_ok()
997                && crate::sealed::write_decimal_map(c"/proc/self/gid_map", Some(gid), gid).is_ok();
998            crate::sealed::restore_dumpable(previous);
999            if mapped {
1000                Ok(())
1001            } else {
1002                Err(())
1003            }
1004        }
1005    }
1006
1007    /// `ip link set lo up`, via the legacy `ifreq` ioctl rather than netlink.
1008    ///
1009    /// The ioctl is a fixed-size struct with no message framing, so it is markedly simpler to do
1010    /// allocation-free inside `pre_exec` than the equivalent `RTM_NEWLINK`. It is not deprecated
1011    /// for this purpose, and every distribution's `ifconfig` still uses it.
1012    ///
1013    /// # Safety
1014    /// Post-`fork()`, pre-exec child context only.
1015    unsafe fn bring_loopback_up() -> Result<(), ()> {
1016        // SAFETY: `ifr` is a live, zeroed `ifreq` whose name field is set to "lo"; both ioctls
1017        // read and write only within it, and the socket is closed on every path.
1018        unsafe {
1019            let sock = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
1020            if sock < 0 {
1021                return Err(());
1022            }
1023            let mut ifr: libc::ifreq = std::mem::zeroed();
1024            ifr.ifr_name[0] = b'l' as libc::c_char;
1025            ifr.ifr_name[1] = b'o' as libc::c_char;
1026            if libc::ioctl(sock, libc::SIOCGIFFLAGS, &mut ifr) < 0 {
1027                libc::close(sock);
1028                return Err(());
1029            }
1030            ifr.ifr_ifru.ifru_flags |= (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short;
1031            let rc = libc::ioctl(sock, libc::SIOCSIFFLAGS, &ifr);
1032            libc::close(sock);
1033            if rc < 0 {
1034                return Err(());
1035            }
1036            Ok(())
1037        }
1038    }
1039
1040    /// `ip route add local default dev lo`, over raw netlink.
1041    ///
1042    /// A route of type `RTN_LOCAL` in table `local` tells the kernel that the destination is one
1043    /// of *its own* addresses, so a `connect()` to any address is looped back and delivered to a
1044    /// listening socket in this namespace — with the original destination preserved and
1045    /// recoverable by `getsockname(2)`. That is the whole interception mechanism: no netfilter, no
1046    /// `iptables` binary, and no rule set that can drift out of step with the manifest. A port
1047    /// nothing is bound to is refused immediately by the namespace instead of stalling through the
1048    /// kernel's full SYN-retry schedule against a blackhole.
1049    ///
1050    /// IPv4 only, deliberately. A capsule's IPv6 destinations have no route at all and fail with
1051    /// `ENETUNREACH`, and the proxy reaches every upstream over the host's own stack, so nothing
1052    /// a capsule can legitimately ask for depends on IPv6 inside the namespace.
1053    ///
1054    /// # Safety
1055    /// Post-`fork()`, pre-exec child context only. The request is built in a fixed stack buffer.
1056    unsafe fn add_local_default_route() -> Result<(), ()> {
1057        // SAFETY: `request` and `address` are live, correctly-sized stack locals; the socket is
1058        // closed on every path.
1059        unsafe {
1060            let mut request = [0u8; ROUTE_REQUEST_LEN];
1061            request[0..4].copy_from_slice(&(ROUTE_REQUEST_LEN as u32).to_ne_bytes());
1062            request[4..6].copy_from_slice(&RTM_NEWROUTE.to_ne_bytes());
1063            request[6..8].copy_from_slice(
1064                &(NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL).to_ne_bytes(),
1065            );
1066            request[8..12].copy_from_slice(&1u32.to_ne_bytes()); // sequence
1067            request[12..16].copy_from_slice(&0u32.to_ne_bytes()); // pid: the kernel assigns
1068
1069            let rtm = NLMSGHDR_LEN;
1070            request[rtm] = libc::AF_INET as u8;
1071            request[rtm + 1] = 0; // dst_len 0 — a default route
1072            request[rtm + 2] = 0; // src_len
1073            request[rtm + 3] = 0; // tos
1074            request[rtm + 4] = RT_TABLE_LOCAL;
1075            request[rtm + 5] = RTPROT_BOOT;
1076            request[rtm + 6] = RT_SCOPE_HOST;
1077            request[rtm + 7] = RTN_LOCAL;
1078            request[rtm + 8..rtm + 12].copy_from_slice(&0u32.to_ne_bytes()); // rtm_flags
1079
1080            let attr = rtm + RTMSG_LEN;
1081            request[attr..attr + 2].copy_from_slice(&(RTATTR_OIF_LEN as u16).to_ne_bytes());
1082            request[attr + 2..attr + 4].copy_from_slice(&RTA_OIF.to_ne_bytes());
1083            request[attr + 4..attr + 8].copy_from_slice(&LOOPBACK_IFINDEX.to_ne_bytes());
1084
1085            let sock = libc::socket(
1086                libc::AF_NETLINK,
1087                libc::SOCK_RAW | libc::SOCK_CLOEXEC,
1088                libc::NETLINK_ROUTE,
1089            );
1090            if sock < 0 {
1091                return Err(());
1092            }
1093            let mut address: libc::sockaddr_nl = std::mem::zeroed();
1094            address.nl_family = libc::AF_NETLINK as u16;
1095            let sent = libc::sendto(
1096                sock,
1097                request.as_ptr().cast(),
1098                ROUTE_REQUEST_LEN,
1099                0,
1100                std::ptr::addr_of!(address).cast(),
1101                std::mem::size_of::<libc::sockaddr_nl>() as libc::socklen_t,
1102            );
1103            if sent < 0 {
1104                libc::close(sock);
1105                return Err(());
1106            }
1107
1108            // Reading the ACK is not optional: netlink reports the real outcome here, and a
1109            // request that was accepted for delivery but rejected by the kernel would otherwise
1110            // look like success and leave the capsule with a namespace it cannot resolve through.
1111            let mut response = [0u8; 256];
1112            let received = libc::recv(sock, response.as_mut_ptr().cast(), response.len(), 0);
1113            libc::close(sock);
1114            if received < (NLMSGHDR_LEN + 4) as isize {
1115                return Err(());
1116            }
1117            if u16::from_ne_bytes([response[4], response[5]]) == NLMSG_ERROR {
1118                let code = i32::from_ne_bytes([
1119                    response[NLMSGHDR_LEN],
1120                    response[NLMSGHDR_LEN + 1],
1121                    response[NLMSGHDR_LEN + 2],
1122                    response[NLMSGHDR_LEN + 3],
1123                ]);
1124                // Netlink spells a plain ACK as an error message carrying code 0.
1125                if code != 0 {
1126                    return Err(());
1127                }
1128            }
1129            Ok(())
1130        }
1131    }
1132
1133    /// A listening TCP socket on `0.0.0.0:port` inside the namespace, or `-1`.
1134    ///
1135    /// The wildcard address is what pairs with the local default route: every address is locally
1136    /// deliverable, so this one socket receives connections addressed to *any* host on this port,
1137    /// and `getsockname(2)` on each accepted connection recovers which one the capsule dialled.
1138    ///
1139    /// # Safety
1140    /// Post-`fork()`, pre-exec child context only.
1141    unsafe fn bind_tcp_listener(port: u16) -> RawFd {
1142        // SAFETY: `address` is a live, zeroed `sockaddr_in`; every failure path closes the fd.
1143        unsafe {
1144            let fd = libc::socket(libc::AF_INET, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0);
1145            if fd < 0 {
1146                return -1;
1147            }
1148            let mut address: libc::sockaddr_in = std::mem::zeroed();
1149            address.sin_family = libc::AF_INET as libc::sa_family_t;
1150            address.sin_port = port.to_be();
1151            address.sin_addr.s_addr = libc::INADDR_ANY.to_be();
1152            if libc::bind(
1153                fd,
1154                std::ptr::addr_of!(address).cast(),
1155                std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
1156            ) != 0
1157                || libc::listen(fd, 128) != 0
1158            {
1159                libc::close(fd);
1160                return -1;
1161            }
1162            fd
1163        }
1164    }
1165
1166    /// A UDP socket bound to `0.0.0.0:53` inside the namespace with `IP_PKTINFO` enabled, or
1167    /// `-1`.
1168    ///
1169    /// The wildcard bind pairs with the local default route: whatever address `/etc/resolv.conf`
1170    /// names, the query is delivered to this one socket. `IP_PKTINFO` is then required rather
1171    /// than a nicety — it is how the proxy recovers the address the query was *sent to*, so its
1172    /// reply can carry that same address as the source. A resolver discards an answer that
1173    /// appears to come from a server it never asked, and the symptom of getting this wrong is
1174    /// "DNS silently does not work".
1175    ///
1176    /// # Safety
1177    /// Post-`fork()`, pre-exec child context only.
1178    unsafe fn bind_dns_socket() -> RawFd {
1179        // SAFETY: as `bind_tcp_listener`.
1180        unsafe {
1181            let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0);
1182            if fd < 0 {
1183                return -1;
1184            }
1185            let enable: libc::c_int = 1;
1186            if libc::setsockopt(
1187                fd,
1188                libc::IPPROTO_IP,
1189                libc::IP_PKTINFO,
1190                std::ptr::addr_of!(enable).cast(),
1191                std::mem::size_of::<libc::c_int>() as libc::socklen_t,
1192            ) != 0
1193            {
1194                libc::close(fd);
1195                return -1;
1196            }
1197            let mut address: libc::sockaddr_in = std::mem::zeroed();
1198            address.sin_family = libc::AF_INET as libc::sa_family_t;
1199            address.sin_port = super::DNS_PORT_BE;
1200            address.sin_addr.s_addr = libc::INADDR_ANY.to_be();
1201            if libc::bind(
1202                fd,
1203                std::ptr::addr_of!(address).cast(),
1204                std::mem::size_of::<libc::sockaddr_in>() as libc::socklen_t,
1205            ) != 0
1206            {
1207                libc::close(fd);
1208                return -1;
1209            }
1210            fd
1211        }
1212    }
1213
1214    /// # Safety
1215    /// Post-`fork()`, pre-exec child context only.
1216    unsafe fn close_all(fds: &[RawFd]) {
1217        for fd in fds {
1218            if *fd >= 0 {
1219                // SAFETY: each entry was returned by a successful `socket` or `open` call above.
1220                unsafe { libc::close(*fd) };
1221            }
1222        }
1223    }
1224
1225    /// Enough room for [`MAX_SESSION_NETNS_FDS`] descriptors plus the `cmsghdr`.
1226    const CMSG_BUFFER_LEN: usize = 128;
1227
1228    // `send_fds`/`receive_session_netns` size `msg_controllen` from
1229    // `libc::CMSG_SPACE(descriptor_bytes)`, independently of `CMSG_BUFFER_LEN`, so a bump of
1230    // `MAX_SESSION_NETNS_FDS` (via `egress_proxy::MAX_EGRESS_TCP_PORTS`) could otherwise make that
1231    // computed length exceed the fixed-size `CmsgBuffer` below, and the CMSG macros would then
1232    // write past its end. Pinned at compile time instead.
1233    const _: () = assert!(
1234        // SAFETY: `CMSG_SPACE` is pure arithmetic on its argument (see libc's definition); it is
1235        // marked `unsafe` only for macro-generation consistency with pointer-taking neighbours
1236        // like `CMSG_DATA`, not because this call touches memory.
1237        unsafe {
1238            libc::CMSG_SPACE((MAX_SESSION_NETNS_FDS * std::mem::size_of::<RawFd>()) as u32) as usize
1239        } <= CMSG_BUFFER_LEN,
1240        "CMSG_BUFFER_LEN is too small to hold MAX_SESSION_NETNS_FDS descriptors plus the \
1241         cmsghdr; raise it to match"
1242    );
1243
1244    /// A `cmsghdr` buffer with the alignment the CMSG macros require.
1245    ///
1246    /// A bare `[u8; N]` is only 1-byte aligned and `CMSG_FIRSTHDR` casts it straight to
1247    /// `*mut cmsghdr`: on a debug build that is a misaligned-pointer panic, and on a release build
1248    /// it is undefined behaviour. Found the hard way while prototyping this hand-off.
1249    #[repr(C, align(8))]
1250    struct CmsgBuffer([u8; CMSG_BUFFER_LEN]);
1251
1252    /// Sends every namespace socket and both namespace handles to the parent in one
1253    /// `SCM_RIGHTS` message.
1254    ///
1255    /// One message rather than one per fd, so the parent's receive is a single unambiguous read:
1256    /// a partial hand-off would leave the proxy serving the endpoint but not the resolver, which
1257    /// presents as "DNS mysteriously does not work" rather than as a failure.
1258    ///
1259    /// # Safety
1260    /// Post-`fork()`, pre-exec child context only; `sendmsg` is async-signal-safe and every
1261    /// buffer is a stack local.
1262    unsafe fn send_fds(sock_fd: RawFd, fds: &[RawFd]) -> Result<(), ()> {
1263        // SAFETY: all buffers are live locals outliving the single `sendmsg`; the control buffer
1264        // is 8-byte aligned, which the CMSG macros require.
1265        unsafe {
1266            let mut payload = [0u8; 1];
1267            let mut iov = libc::iovec {
1268                iov_base: payload.as_mut_ptr().cast(),
1269                iov_len: payload.len(),
1270            };
1271            let mut control = CmsgBuffer([0u8; CMSG_BUFFER_LEN]);
1272            let bytes = std::mem::size_of_val(fds) as u32;
1273
1274            let mut msg: libc::msghdr = std::mem::zeroed();
1275            msg.msg_iov = &mut iov;
1276            msg.msg_iovlen = 1;
1277            msg.msg_control = control.0.as_mut_ptr().cast();
1278            msg.msg_controllen = libc::CMSG_SPACE(bytes) as _;
1279
1280            let cmsg = libc::CMSG_FIRSTHDR(&msg);
1281            if cmsg.is_null() {
1282                return Err(());
1283            }
1284            (*cmsg).cmsg_level = libc::SOL_SOCKET;
1285            (*cmsg).cmsg_type = libc::SCM_RIGHTS;
1286            (*cmsg).cmsg_len = libc::CMSG_LEN(bytes) as _;
1287            std::ptr::copy_nonoverlapping(
1288                fds.as_ptr().cast::<u8>(),
1289                libc::CMSG_DATA(cmsg),
1290                bytes as usize,
1291            );
1292
1293            if libc::sendmsg(sock_fd, &msg, 0) < 0 {
1294                return Err(());
1295            }
1296            Ok(())
1297        }
1298    }
1299
1300    /// Parent-side counterpart of [`send_fds`]: takes the creator's one message off the queue.
1301    ///
1302    /// Non-blocking, because the creator has already been reaped by the time this runs: whatever
1303    /// it sent is queued, and an empty queue means it sent nothing. A descriptor count other than
1304    /// `expected_sockets + 2` is an error rather than a best-effort start, because a proxy missing
1305    /// one of its sockets is a capsule whose network half-works in a way nothing downstream would
1306    /// report.
1307    fn receive_session_netns(sock_fd: RawFd, expected_sockets: usize) -> Result<Received, String> {
1308        // SAFETY: `sock_fd` is a valid unix socket; all buffers are live locals sized for the
1309        // largest message the creator can send, and the control buffer is 8-byte aligned.
1310        unsafe {
1311            let mut payload = [0u8; FAILURE_RECORD_LEN];
1312            let mut iov = libc::iovec {
1313                iov_base: payload.as_mut_ptr().cast(),
1314                iov_len: payload.len(),
1315            };
1316            let mut control = CmsgBuffer([0u8; CMSG_BUFFER_LEN]);
1317            let mut msg: libc::msghdr = std::mem::zeroed();
1318            msg.msg_iov = &mut iov;
1319            msg.msg_iovlen = 1;
1320            msg.msg_control = control.0.as_mut_ptr().cast();
1321            msg.msg_controllen = control.0.len() as _;
1322
1323            let received = libc::recvmsg(
1324                sock_fd,
1325                &mut msg,
1326                libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC,
1327            );
1328            if received < 0 {
1329                let error = io::Error::last_os_error();
1330                if error.kind() == io::ErrorKind::WouldBlock {
1331                    return Ok(Received::Nothing);
1332                }
1333                return Err(format!("receiving the namespace hand-off failed: {error}"));
1334            }
1335
1336            let cmsg = libc::CMSG_FIRSTHDR(&msg);
1337            if cmsg.is_null() {
1338                if received as usize == FAILURE_RECORD_LEN {
1339                    if let Some(failure) = CreationFailure::decode(&payload) {
1340                        return Ok(Received::Failure(failure));
1341                    }
1342                }
1343                return Ok(Received::Nothing);
1344            }
1345            if (*cmsg).cmsg_level != libc::SOL_SOCKET || (*cmsg).cmsg_type != libc::SCM_RIGHTS {
1346                return Err(
1347                    "the namespace hand-off did not arrive as an SCM_RIGHTS message".to_string(),
1348                );
1349            }
1350            let bytes = (*cmsg).cmsg_len - libc::CMSG_LEN(0) as usize;
1351            let count = bytes / std::mem::size_of::<RawFd>();
1352            let data = libc::CMSG_DATA(cmsg).cast::<RawFd>();
1353            // Wrapped in `OwnedFd` before anything is checked, so a mismatched or truncated
1354            // message closes its descriptors on the error path instead of leaking them.
1355            let mut fds = Vec::with_capacity(count);
1356            for index in 0..count {
1357                fds.push(OwnedFd::from_raw_fd(std::ptr::read_unaligned(
1358                    data.add(index),
1359                )));
1360            }
1361            if msg.msg_flags & libc::MSG_CTRUNC != 0 {
1362                return Err("the namespace hand-off was truncated".to_string());
1363            }
1364            if count != expected_sockets + 2 {
1365                return Err(format!(
1366                    "the namespace creator handed over {count} descriptors; {} were expected \
1367                     ({expected_sockets} sockets and 2 namespace handles)",
1368                    expected_sockets + 2
1369                ));
1370            }
1371            Ok(Received::Handles(fds))
1372        }
1373    }
1374}
1375
1376/// [`crate::egress_proxy::EGRESS_DNS_PORT`] in network byte order, so the `pre_exec` window does
1377/// no arithmetic the parent could have done for it.
1378#[cfg(target_os = "linux")]
1379const DNS_PORT_BE: u16 = crate::egress_proxy::EGRESS_DNS_PORT.to_be();
1380
1381#[cfg(test)]
1382mod tests {
1383    use super::*;
1384
1385    fn support(apparmor: bool, namespace: EgressNamespaceProbe) -> EgressNamespaceSupport {
1386        EgressNamespaceSupport {
1387            userns_grant: if apparmor {
1388                crate::sealed::UsernsGrant::ProfileConfining
1389            } else {
1390                crate::sealed::UsernsGrant::Withheld
1391            },
1392            namespace,
1393        }
1394    }
1395
1396    /// The blocker decision reads only `UsernsGrant::permits_userns()`. Every grant that permits
1397    /// the namespace must therefore reach the same verdict — naming the provenance reports a host
1398    /// fact and never changes one.
1399    #[test]
1400    fn every_permitting_userns_grant_reaches_the_same_verdict() {
1401        for grant in crate::sealed::UsernsGrant::ALL {
1402            let support = EgressNamespaceSupport {
1403                userns_grant: *grant,
1404                namespace: EgressNamespaceProbe::Ok,
1405            };
1406            let expected = if grant.permits_userns() {
1407                None
1408            } else {
1409                Some(EgressNamespaceBlocker::CapabilityGrantMissing)
1410            };
1411            assert_eq!(
1412                egress_namespace_blocker(true, support),
1413                expected,
1414                "grant {grant:?} must decide only through permits_userns()"
1415            );
1416        }
1417    }
1418
1419    /// Both refusal texts put the profile first and label the sysctl as what it costs. The sysctl
1420    /// is a legitimate answer on a host where no profile can be loaded, so the text still carries
1421    /// it — but a reader must not be able to take it for a peer alternative to the profile.
1422    #[test]
1423    fn the_capability_grant_reason_offers_the_profile_before_the_sysctl() {
1424        let reason = EgressNamespaceBlocker::CapabilityGrantMissing.reason();
1425        let profile = reason
1426            .find("apparmor_parser -r")
1427            .expect("the profile install command must be named");
1428        let sysctl = reason
1429            .find("kernel.apparmor_restrict_unprivileged_userns=0")
1430            .expect("the fallback must still be named for hosts that cannot load a profile");
1431        assert!(
1432            profile < sysctl,
1433            "the profile install must come first: {reason}"
1434        );
1435        assert!(
1436            reason.contains("LAST RESORT"),
1437            "the sysctl must be labelled a fallback, not offered as a peer: {reason}"
1438        );
1439        assert!(
1440            reason.contains("every program on the machine"),
1441            "the sysctl's host-wide cost must be stated: {reason}"
1442        );
1443    }
1444
1445    // ---- `egress_namespace_blocker` ------------------------------------------------------
1446
1447    #[test]
1448    fn a_working_namespace_blocks_nothing() {
1449        assert_eq!(
1450            egress_namespace_blocker(true, support(true, EgressNamespaceProbe::Ok)),
1451            None
1452        );
1453    }
1454
1455    #[test]
1456    fn apparmor_is_blamed_before_the_namespace_outcome() {
1457        // The `unshare` failure is a *consequence* of the restriction, so naming the kernel here
1458        // would send an Ubuntu desktop user to entirely the wrong fix.
1459        assert_eq!(
1460            egress_namespace_blocker(true, support(false, EgressNamespaceProbe::Unsupported)),
1461            Some(EgressNamespaceBlocker::CapabilityGrantMissing)
1462        );
1463    }
1464
1465    #[test]
1466    fn every_refusal_of_a_supported_mechanism_is_a_capability_grant_problem() {
1467        for probe in [
1468            EgressNamespaceProbe::Denied,
1469            EgressNamespaceProbe::MapDenied,
1470            EgressNamespaceProbe::ConfigDenied,
1471        ] {
1472            assert_eq!(
1473                egress_namespace_blocker(true, support(true, probe)),
1474                Some(EgressNamespaceBlocker::CapabilityGrantMissing),
1475                "{probe:?}"
1476            );
1477        }
1478    }
1479
1480    #[test]
1481    fn a_kernel_without_the_mechanism_is_named_separately() {
1482        assert_eq!(
1483            egress_namespace_blocker(true, support(true, EgressNamespaceProbe::Unsupported)),
1484            Some(EgressNamespaceBlocker::KernelSupportMissing)
1485        );
1486    }
1487
1488    #[test]
1489    fn non_linux_is_never_refused_for_a_mechanism_it_never_had() {
1490        for probe in [
1491            EgressNamespaceProbe::Ok,
1492            EgressNamespaceProbe::Denied,
1493            EgressNamespaceProbe::Unsupported,
1494        ] {
1495            assert_eq!(egress_namespace_blocker(false, support(false, probe)), None);
1496        }
1497    }
1498
1499    // ---- refusal text --------------------------------------------------------------------
1500
1501    #[test]
1502    fn the_capability_grant_refusal_names_both_remediations() {
1503        let reason = EgressNamespaceBlocker::CapabilityGrantMissing.reason();
1504        assert!(reason.contains("apparmor_parser"), "names the AppArmor fix");
1505        assert!(
1506            reason.contains("--cap-add SYS_ADMIN"),
1507            "names the container fix"
1508        );
1509    }
1510
1511    #[test]
1512    fn the_kernel_refusal_names_the_sysctl_and_the_config_symbol() {
1513        let reason = EgressNamespaceBlocker::KernelSupportMissing.reason();
1514        assert!(reason.contains("user.max_user_namespaces"));
1515        assert!(reason.contains("CONFIG_USER_NS"));
1516    }
1517
1518    // ---- the socket plan --------------------------------------------------------------------
1519
1520    #[test]
1521    fn the_socket_count_is_one_per_port_plus_the_resolver() {
1522        let plan = CapsuleNetnsPlan {
1523            tcp_ports: vec![80, 443],
1524            uid: 1000,
1525            gid: 1000,
1526        };
1527        assert_eq!(plan.socket_count(), 3);
1528    }
1529
1530    #[test]
1531    fn a_capsule_with_no_tcp_egress_still_binds_the_resolver() {
1532        // Load-bearing: with nothing bound on 53 a lookup would be a dropped packet, and the
1533        // capsule would stall through glibc's whole retry schedule instead of being told no.
1534        let plan = CapsuleNetnsPlan {
1535            tcp_ports: Vec::new(),
1536            uid: 1000,
1537            gid: 1000,
1538        };
1539        assert_eq!(plan.socket_count(), 1);
1540    }
1541
1542    #[test]
1543    fn the_widest_allowlist_still_fits_one_scm_rights_message() {
1544        let plan = CapsuleNetnsPlan {
1545            tcp_ports: (0..crate::egress_proxy::MAX_EGRESS_TCP_PORTS as u16).collect(),
1546            uid: 1000,
1547            gid: 1000,
1548        };
1549        assert!(plan.socket_count() <= MAX_NAMESPACE_SOCKETS);
1550        // The two namespace handles ride in the same message.
1551        assert_eq!(MAX_SESSION_NETNS_FDS, MAX_NAMESPACE_SOCKETS + 2);
1552    }
1553
1554    // ---- the session's namespace ---------------------------------------------------------
1555
1556    /// An ended session is never given a namespace, including one that never created one.
1557    #[cfg(target_os = "linux")]
1558    #[test]
1559    fn an_ended_session_refuses_to_create_a_namespace() {
1560        let netns = SessionNetns::new();
1561        netns.end();
1562        let refusal = netns
1563            .establish(
1564                &[],
1565                crate::egress_proxy::EgressPolicy::new(Vec::new(), Vec::new()),
1566            )
1567            .expect_err("an ended session creates nothing");
1568        assert!(refusal.contains("session has ended"), "{refusal}");
1569        assert_eq!(netns.creations(), 0);
1570    }
1571
1572    /// One creation serves every later call, and the namespace it made is the one each call
1573    /// is handed; ending it is final and repeatable.
1574    #[cfg(target_os = "linux")]
1575    #[test]
1576    fn a_session_creates_its_namespace_once_and_ends_it_for_good() {
1577        if skip_without_egress_namespace(
1578            "a_session_creates_its_namespace_once_and_ends_it_for_good",
1579        ) {
1580            return;
1581        }
1582        let policy = || crate::egress_proxy::EgressPolicy::new(Vec::new(), Vec::new());
1583        let netns = SessionNetns::new();
1584        assert_eq!(
1585            netns.creations(),
1586            0,
1587            "nothing is created before it is needed"
1588        );
1589
1590        let first = netns
1591            .establish(&[], policy())
1592            .expect("the namespace is created");
1593        let second = netns
1594            .establish(&[], policy())
1595            .expect("the namespace is reused");
1596        assert_eq!(netns.creations(), 1);
1597        let inode = |fd: std::os::fd::RawFd| {
1598            std::fs::metadata(format!("/proc/self/fd/{fd}"))
1599                .map(|metadata| std::os::unix::fs::MetadataExt::ino(&metadata))
1600                .unwrap()
1601        };
1602        assert_eq!(inode(first.raw_fds().1), inode(second.raw_fds().1));
1603        let host_netns = std::fs::metadata("/proc/self/ns/net")
1604            .map(|metadata| std::os::unix::fs::MetadataExt::ino(&metadata))
1605            .unwrap();
1606        assert_ne!(
1607            inode(first.raw_fds().1),
1608            host_netns,
1609            "the session's namespace is not the runtime's own"
1610        );
1611
1612        netns.end();
1613        netns.end();
1614        assert!(netns.establish(&[], policy()).is_err());
1615        assert_eq!(
1616            netns.creations(),
1617            1,
1618            "an ended namespace is never recreated"
1619        );
1620    }
1621
1622    /// The creator's failure record survives the round trip, so the parent names the step and
1623    /// the errno rather than a bare exit status.
1624    #[cfg(target_os = "linux")]
1625    #[test]
1626    fn a_creation_failure_names_its_step_and_errno() {
1627        let text = linux::describe_failure_record(4, libc::EPERM).expect("a known step decodes");
1628        assert!(
1629            text.starts_with("installing the local default route failed:"),
1630            "{text}"
1631        );
1632        assert!(text.contains("Operation not permitted"), "{text}");
1633        assert_eq!(linux::describe_failure_record(99, 0), None);
1634    }
1635}