Skip to main content

capsule_runtime/
namespace_probe.rs

1//! The one place the host's namespaces are probed.
2//!
3//! Every decision that depends on what namespaces this host can give a capsule reads a
4//! [`NamespaceReading`]: the `E-CAP-005` network-namespace check, whether a command's literal
5//! `/tmp` can be mapped onto the session's temporary folder, whether `sealed` is refused
6//! (`E-CAP-003`), the enforcement tier, `mur run --explain-scope` and `mur doctor`. A reading is
7//! taken once per [`crate::sandbox::HostProbe`], so one staged session judges one reading.
8//!
9//! The probe tries, in order, exactly what a session does:
10//!
11//!   1. **network** — the session's own creator ([`crate::network_namespace`]):
12//!      `unshare(CLONE_NEWUSER | CLONE_NEWNET)`, identity maps, `lo`, the local route, the
13//!      sockets and the hand-off.
14//!   2. **mount** — in a child that joins that namespace the way every command does
15//!      (`join_session_netns`), the private mount namespace a command's `/tmp` mapping builds:
16//!      `unshare(CLONE_NEWNS)`, `MS_REC | MS_PRIVATE`, a recursive bind over `/tmp`.
17//!   3. **sealed root** — in a second joining child, the composed root's nested construction:
18//!      `unshare(CLONE_NEWUSER | CLONE_NEWNS)`, identity maps, `MS_REC | MS_PRIVATE`, a `tmpfs`,
19//!      `pivot_root(2)`, detaching the old root.
20//!
21//! The two mount attempts run inside the probe's network namespace because no command runs
22//! outside one: a command joins its session's namespace first, on every tier, and only then builds
23//! its mount namespace. A host that cannot create the network namespace attempts neither.
24//!
25//! AppArmor's unprivileged-userns restriction and the profile confining `mur` are read with the
26//! attempts, and only ever choose the words of a refusal: no decision reads them.
27
28#[cfg(any(target_os = "linux", test))]
29use crate::network_namespace::{decode_step_record, STEP_RECORD_LEN};
30use crate::network_namespace::{
31    errno_label, errno_words, EgressNamespaceFailure, NamespaceAttemptError,
32};
33use crate::sealed::{ProfileAttachment, UsernsGrant};
34
35/// One step of a mount rehearsal, as the child reports it when that step fails.
36///
37/// `JoinSession` is shared by both rehearsals; `UnshareMount`, `MakePrivate` and `BindTmp` are the
38/// `/tmp` mapping's; the rest are the composed root's.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum MountStep {
41    JoinSession = 1,
42    UnshareMount = 2,
43    MakePrivate = 3,
44    BindTmp = 4,
45    UnshareUserMount = 5,
46    IdentityMaps = 6,
47    MakePrivateNested = 7,
48    Tmpfs = 8,
49    PivotPrepare = 9,
50    PivotRoot = 10,
51    DetachOldRoot = 11,
52}
53
54impl MountStep {
55    // Only the Linux probe decodes a step; tests on every OS check the encoding round-trips.
56    #[cfg(any(target_os = "linux", test))]
57    const ALL: [MountStep; 11] = [
58        Self::JoinSession,
59        Self::UnshareMount,
60        Self::MakePrivate,
61        Self::BindTmp,
62        Self::UnshareUserMount,
63        Self::IdentityMaps,
64        Self::MakePrivateNested,
65        Self::Tmpfs,
66        Self::PivotPrepare,
67        Self::PivotRoot,
68        Self::DetachOldRoot,
69    ];
70
71    /// The step a rehearsal record's code names, or `None` for a code no step carries.
72    #[cfg(any(target_os = "linux", test))]
73    fn from_code(code: u32) -> Option<Self> {
74        Self::ALL.into_iter().find(|step| *step as u32 == code)
75    }
76
77    /// The call or operation, as a refusal names it.
78    #[must_use]
79    pub fn describe(self) -> &'static str {
80        match self {
81            Self::JoinSession => "joining the session's user and network namespaces (setns)",
82            Self::UnshareMount => "unshare(CLONE_NEWNS)",
83            Self::MakePrivate => "making / MS_REC|MS_PRIVATE",
84            Self::BindTmp => "bind-mounting over /tmp",
85            Self::UnshareUserMount => {
86                "unshare(CLONE_NEWUSER | CLONE_NEWNS) inside the session's user namespace"
87            }
88            Self::IdentityMaps => "writing uid_map/gid_map",
89            Self::MakePrivateNested => "making / MS_REC|MS_PRIVATE in the nested namespace",
90            Self::Tmpfs => "mounting the composed root's tmpfs",
91            Self::PivotPrepare => "creating the old root's parking directory and entering the root",
92            Self::PivotRoot => "pivot_root(2)",
93            Self::DetachOldRoot => "detaching the old root",
94        }
95    }
96}
97
98/// Why a mount rehearsal produced no namespace.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum MountAttemptError {
101    /// The rehearsal ran, and `step` failed with `errno`.
102    Step { step: MountStep, errno: i32 },
103    /// The rehearsal child could not be forked, piped or waited for, or exited without reporting;
104    /// the runtime's own account of it.
105    Rehearsal(String),
106}
107
108/// What one mount rehearsal did.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum MountAttempt {
111    /// Every step succeeded.
112    Created,
113    /// A step failed, or the rehearsal could not run.
114    Failed(MountAttemptError),
115    /// Not tried: the network namespace it nests in could not be created, or the host is not
116    /// Linux.
117    NotAttempted,
118}
119
120impl MountAttempt {
121    /// `<call> failed with <ERRNO>`, the rehearsal's own account, or `None` when nothing failed:
122    /// the one-line answer `mur doctor` prints.
123    #[must_use]
124    pub fn failure_summary(&self) -> Option<String> {
125        match self {
126            MountAttempt::Failed(MountAttemptError::Step { step, errno }) => Some(format!(
127                "{} failed with {}",
128                step.describe(),
129                errno_label(*errno)
130            )),
131            MountAttempt::Failed(MountAttemptError::Rehearsal(detail)) => Some(detail.clone()),
132            MountAttempt::Created | MountAttempt::NotAttempted => None,
133        }
134    }
135}
136
137/// The host's namespaces as one probe found them: the three attempts, in the order a session
138/// makes them, and the two readings a refusal is worded from.
139///
140/// Off Linux nothing is attempted and nothing fails: `network` is `Ok(())` because no attempt
141/// failed — there is no network namespace to create — and both mount attempts are
142/// [`MountAttempt::NotAttempted`]. Every question asked of the reading therefore answers that no
143/// namespace was created and none was refused: [`Self::mount_namespace_created`] and
144/// [`Self::sealed_root_created`] are `false` and [`Self::egress_namespace_failure`] is `None`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct NamespaceReading {
147    /// The session's network namespace creation.
148    pub network: Result<(), NamespaceAttemptError>,
149    /// The private mount namespace a command's `/tmp` mapping builds, inside the network
150    /// namespace.
151    pub mount: MountAttempt,
152    /// The composed root's nested user+mount namespace and `pivot_root(2)`, inside the network
153    /// namespace.
154    pub sealed_root: MountAttempt,
155    /// AppArmor's unprivileged-userns restriction as read with the attempts. Words a refusal;
156    /// decides nothing.
157    pub userns_grant: UsernsGrant,
158    /// The profile the kernel reports as confining this process, read with the attempts. Words a
159    /// refusal; decides nothing.
160    pub attachment: ProfileAttachment,
161}
162
163impl NamespaceReading {
164    /// The reading of a host that is not Linux. See the type's documentation.
165    #[must_use]
166    pub fn not_linux() -> Self {
167        Self {
168            network: Ok(()),
169            mount: MountAttempt::NotAttempted,
170            sealed_root: MountAttempt::NotAttempted,
171            userns_grant: UsernsGrant::default(),
172            attachment: ProfileAttachment::NotReported,
173        }
174    }
175
176    /// The `E-CAP-005` failure the network attempt is, or `None` when it created the namespace
177    /// (or, off Linux, when there was none to create).
178    #[must_use]
179    pub fn egress_namespace_failure(&self) -> Option<EgressNamespaceFailure> {
180        crate::network_namespace::egress_namespace_failure(
181            cfg!(target_os = "linux"),
182            self.network.clone(),
183            self.userns_grant,
184            self.attachment.clone(),
185        )
186    }
187
188    /// Whether a command can get a private mount namespace inside the session's user namespace,
189    /// which is what maps its literal `/tmp`.
190    #[must_use]
191    pub fn mount_namespace_created(&self) -> bool {
192        self.network.is_ok() && self.mount == MountAttempt::Created
193    }
194
195    /// Whether the composed root `sealed` runs in was built: the nested user+mount namespace,
196    /// its mounts and `pivot_root(2)`.
197    #[must_use]
198    pub fn sealed_root_created(&self) -> bool {
199        self.network.is_ok() && self.sealed_root == MountAttempt::Created
200    }
201
202    /// Whether a user+mount namespace, its identity maps and private propagation can be created
203    /// inside the session's namespace — the sealed-root rehearsal got past
204    /// [`MountStep::MakePrivateNested`]. What a test that builds a bind mount in its own
205    /// user+mount namespace needs to ask.
206    #[must_use]
207    pub fn user_mount_namespace_usable(&self) -> bool {
208        self.network.is_ok()
209            && match &self.sealed_root {
210                MountAttempt::Created => true,
211                MountAttempt::Failed(MountAttemptError::Step { step, .. }) => matches!(
212                    step,
213                    MountStep::Tmpfs
214                        | MountStep::PivotPrepare
215                        | MountStep::PivotRoot
216                        | MountStep::DetachOldRoot
217                ),
218                MountAttempt::Failed(MountAttemptError::Rehearsal(_))
219                | MountAttempt::NotAttempted => false,
220            }
221    }
222
223    /// The first attempt that failed, as `<call> failed with <ERRNO>` or the runtime's account,
224    /// or `None` when none did.
225    #[must_use]
226    pub fn first_failure(&self) -> Option<String> {
227        if let Some(failure) = self.egress_namespace_failure() {
228            return Some(failure.summary());
229        }
230        self.sealed_root
231            .failure_summary()
232            .or_else(|| self.mount.failure_summary())
233    }
234}
235
236/// `The probe's <call> failed with <ERRNO> (<words>).`, the sentence an `E-CAP-003` for `sealed`
237/// names the failed attempt with.
238pub(crate) fn probe_failure_sentence(failed_call: &str, errno: i32) -> String {
239    format!(
240        "The probe's {failed_call} failed with {} ({}).",
241        errno_label(errno),
242        errno_words(errno)
243    )
244}
245
246/// Reads the host's namespaces: the three attempts, in order, and the readings.
247///
248/// Every `unshare` and `setns` happens in a child, so the calling process is unchanged, and the
249/// namespace handles are closed before this returns.
250#[cfg(target_os = "linux")]
251pub(crate) fn probe_namespaces() -> NamespaceReading {
252    use crate::network_namespace::{create_session_netns, CapsuleNetnsPlan};
253
254    let (network, mount, sealed_root) =
255        match create_session_netns(&CapsuleNetnsPlan::resolve(Vec::new())) {
256            Err(error) => (
257                Err(error),
258                MountAttempt::NotAttempted,
259                MountAttempt::NotAttempted,
260            ),
261            Ok(created) => {
262                let (user_fd, net_fd) = created.namespace_handles();
263                let mount = linux::rehearse(linux::Rehearsal::PrivateTmp, user_fd, net_fd);
264                let sealed_root = linux::rehearse(linux::Rehearsal::SealedRoot, user_fd, net_fd);
265                drop(created);
266                (Ok(()), mount, sealed_root)
267            }
268        };
269    NamespaceReading {
270        network,
271        mount,
272        sealed_root,
273        userns_grant: crate::sealed::userns_grant(),
274        attachment: crate::sealed::inspect_profile_attachment(),
275    }
276}
277
278#[cfg(not(target_os = "linux"))]
279pub(crate) fn probe_namespaces() -> NamespaceReading {
280    NamespaceReading::not_linux()
281}
282
283/// What a rehearsal's step report says: `Created` for code `0`, the failed step otherwise, or
284/// `None` for a code no step carries.
285#[cfg(any(target_os = "linux", test))]
286fn decode_record(record: &[u8; STEP_RECORD_LEN]) -> Option<MountAttempt> {
287    let (code, errno) = decode_step_record(record);
288    if code == 0 {
289        return Some(MountAttempt::Created);
290    }
291    MountStep::from_code(code)
292        .map(|step| MountAttempt::Failed(MountAttemptError::Step { step, errno }))
293}
294
295#[cfg(target_os = "linux")]
296#[allow(unsafe_code)]
297mod linux {
298    use std::ffi::CString;
299    use std::io;
300    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
301    use std::os::unix::ffi::OsStrExt;
302
303    use super::{decode_record, MountAttempt, MountAttemptError, MountStep, STEP_RECORD_LEN};
304    use crate::network_namespace::encode_step_record;
305    use crate::sealed::{
306        make_dumpable_for_map_writes, restore_dumpable, write_decimal_map, OLD_ROOT_NAME_C,
307        OLD_ROOT_PIVOTED_C, SEALED_ROOT_BASE_CANDIDATES,
308    };
309
310    /// Which of the two mount rehearsals to run.
311    #[derive(Debug, Clone, Copy)]
312    pub(super) enum Rehearsal {
313        /// A command's `/tmp` mapping: `unshare(CLONE_NEWNS)`, private propagation, a bind over
314        /// `/tmp`.
315        PrivateTmp,
316        /// The composed root's nested namespace, maps, mounts and pivot.
317        SealedRoot,
318    }
319
320    impl Rehearsal {
321        fn name(self) -> &'static str {
322            match self {
323                Rehearsal::PrivateTmp => "mount rehearsal",
324                Rehearsal::SealedRoot => "sealed-root rehearsal",
325            }
326        }
327    }
328
329    /// Every path the child uses, built in the parent before `fork()`.
330    struct Paths {
331        /// `/tmp`, canonicalised.
332        tmp: CString,
333        /// Each root base candidate with its parking directory's full path.
334        bases: Vec<(CString, CString)>,
335        uid: u32,
336        gid: u32,
337    }
338
339    impl Paths {
340        fn resolve() -> Result<Self, String> {
341            let tmp = std::fs::canonicalize("/tmp").unwrap_or_else(|_| "/tmp".into());
342            let tmp = CString::new(tmp.as_os_str().as_bytes())
343                .map_err(|_| "the canonical /tmp contains a NUL byte".to_string())?;
344            let mut bases = Vec::with_capacity(SEALED_ROOT_BASE_CANDIDATES.len());
345            for base in SEALED_ROOT_BASE_CANDIDATES {
346                let parked = format!(
347                    "{base}/{}",
348                    OLD_ROOT_NAME_C.to_str().expect("the parking name is ASCII")
349                );
350                bases.push((
351                    CString::new(*base).expect("candidates carry no NUL"),
352                    CString::new(parked).expect("candidates carry no NUL"),
353                ));
354            }
355            // SAFETY: `getuid`/`getgid` take no arguments, dereference nothing and cannot fail.
356            // Read here, before the fork: inside a fresh user namespace with no map written yet
357            // they report the overflow id, which no `uid_map` accepts.
358            let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
359            Ok(Self {
360                tmp,
361                bases,
362                uid,
363                gid,
364            })
365        }
366    }
367
368    /// Runs `rehearsal` in a child that first joins the namespaces `user_fd` and `net_fd` name,
369    /// and reports what it did.
370    pub(super) fn rehearse(rehearsal: Rehearsal, user_fd: RawFd, net_fd: RawFd) -> MountAttempt {
371        let failed = |detail: String| MountAttempt::Failed(MountAttemptError::Rehearsal(detail));
372        let paths = match Paths::resolve() {
373            Ok(paths) => paths,
374            Err(detail) => {
375                return failed(format!("the {} could not run: {detail}", rehearsal.name()))
376            }
377        };
378
379        let mut pipe = [0i32; 2];
380        // SAFETY: `pipe` is a 2-element stack array, exactly the size `pipe2` writes into.
381        if unsafe { libc::pipe2(pipe.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) } != 0 {
382            return failed(format!(
383                "pipe2() for the {} failed: {}",
384                rehearsal.name(),
385                io::Error::last_os_error()
386            ));
387        }
388        // SAFETY: both fds were just returned by the successful `pipe2` and nothing else owns
389        // them.
390        let (read_end, write_end) =
391            unsafe { (OwnedFd::from_raw_fd(pipe[0]), OwnedFd::from_raw_fd(pipe[1])) };
392        let write_fd = write_end.as_raw_fd();
393
394        // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
395        // touches nothing but async-signal-safe primitives. The child runs only `setns`,
396        // `unshare`, `prctl`, `mount`, `open`/`write`/`close`, `mkdir`, `chdir`, `pivot_root`
397        // via `syscall` and `umount2` over paths built above, then `write` and `_exit`: no
398        // allocation, no locks, no stdio.
399        let pid = unsafe { libc::fork() };
400        if pid < 0 {
401            return failed(format!(
402                "fork() for the {} failed: {}",
403                rehearsal.name(),
404                io::Error::last_os_error()
405            ));
406        }
407        if pid == 0 {
408            // SAFETY: forked-child context, syscalls only; the block ends in `_exit`, so no
409            // destructor of the parent's state runs here.
410            unsafe {
411                let record = match run(rehearsal, &paths, user_fd, net_fd) {
412                    Ok(()) => encode_step_record(0, 0),
413                    Err((step, errno)) => encode_step_record(step as u32, errno),
414                };
415                libc::write(write_fd, record.as_ptr().cast(), record.len());
416                libc::_exit(0);
417            }
418        }
419        drop(write_end);
420
421        let status = match crate::network_namespace::reap_child(pid) {
422            Ok(status) => status,
423            Err(error) => {
424                return failed(format!(
425                    "waiting for the {} failed: {error}",
426                    rehearsal.name()
427                ))
428            }
429        };
430        // Read after the child is reaped and without blocking: the record it wrote stays in the
431        // pipe, and another thread's child forked in the meantime may still hold the write end.
432        let mut record = [0u8; STEP_RECORD_LEN];
433        // SAFETY: `record` is a live stack buffer of `STEP_RECORD_LEN` bytes.
434        let read = unsafe {
435            libc::read(
436                read_end.as_raw_fd(),
437                record.as_mut_ptr().cast(),
438                STEP_RECORD_LEN,
439            )
440        };
441        if read == STEP_RECORD_LEN as isize {
442            if let Some(attempt) = decode_record(&record) {
443                return attempt;
444            }
445        }
446        failed(format!(
447            "the {} exited without reporting ({status})",
448            rehearsal.name()
449        ))
450    }
451
452    /// The rehearsal's steps, each failure the step and the `errno` it left.
453    ///
454    /// # Safety
455    /// Post-`fork()` child context only. Every call is async-signal-safe and nothing here
456    /// allocates: every path is in `paths`, built before the fork.
457    unsafe fn run(
458        rehearsal: Rehearsal,
459        paths: &Paths,
460        user_fd: RawFd,
461        net_fd: RawFd,
462    ) -> Result<(), (MountStep, i32)> {
463        // SAFETY: the caller's contract covers this whole body.
464        unsafe {
465            let errno = || *libc::__errno_location();
466            // Every command joins its session's namespaces before anything else, on every tier.
467            if let Err(error) = crate::network_namespace::join_session_netns(user_fd, net_fd) {
468                return Err((MountStep::JoinSession, error.raw_os_error().unwrap_or(0)));
469            }
470            match rehearsal {
471                // `temp_dir::map_private_tmp`'s namespace and propagation, then a bind over `/tmp`
472                // with its flags: recursive, because a mount namespace owned by a less privileged
473                // user namespace refuses a plain bind of a tree with mounts beneath it (`EINVAL`).
474                Rehearsal::PrivateTmp => {
475                    if libc::unshare(libc::CLONE_NEWNS) != 0 {
476                        return Err((MountStep::UnshareMount, errno()));
477                    }
478                    if make_private() != 0 {
479                        return Err((MountStep::MakePrivate, errno()));
480                    }
481                    if libc::mount(
482                        paths.tmp.as_ptr(),
483                        paths.tmp.as_ptr(),
484                        std::ptr::null(),
485                        libc::MS_BIND | libc::MS_REC,
486                        std::ptr::null(),
487                    ) != 0
488                    {
489                        return Err((MountStep::BindTmp, errno()));
490                    }
491                }
492                // `sealed::construct_composed_root`'s steps 1–4, 6 and 7, with the same syscalls
493                // in the same order.
494                Rehearsal::SealedRoot => {
495                    if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
496                        return Err((MountStep::UnshareUserMount, errno()));
497                    }
498                    // Dumpable for the map writes only: a non-dumpable task's `/proc/self/*`
499                    // entries are root-owned (see `sealed::make_dumpable_for_map_writes`).
500                    let previous = make_dumpable_for_map_writes();
501                    let _ = write_decimal_map(c"/proc/self/setgroups", None, 0);
502                    let mapped =
503                        write_decimal_map(c"/proc/self/uid_map", Some(paths.uid), paths.uid)
504                            .is_ok()
505                            && write_decimal_map(c"/proc/self/gid_map", Some(paths.gid), paths.gid)
506                                .is_ok();
507                    let map_errno = errno();
508                    restore_dumpable(previous);
509                    if !mapped {
510                        return Err((MountStep::IdentityMaps, map_errno));
511                    }
512                    if make_private() != 0 {
513                        return Err((MountStep::MakePrivateNested, errno()));
514                    }
515                    // The first candidate that takes a `tmpfs`; the real construction picks one
516                    // that is no ancestor of the workdir, which a rehearsal has none of.
517                    let mut based = None;
518                    let mut tmpfs_errno = 0;
519                    for (base, parked) in &paths.bases {
520                        if libc::mount(
521                            c"tmpfs".as_ptr(),
522                            base.as_ptr(),
523                            c"tmpfs".as_ptr(),
524                            libc::MS_NOSUID | libc::MS_NODEV,
525                            c"mode=0755".as_ptr().cast(),
526                        ) == 0
527                        {
528                            based = Some((base, parked));
529                            break;
530                        }
531                        tmpfs_errno = errno();
532                    }
533                    let Some((base, parked)) = based else {
534                        return Err((MountStep::Tmpfs, tmpfs_errno));
535                    };
536                    if libc::mkdir(parked.as_ptr(), 0o700) != 0 {
537                        return Err((MountStep::PivotPrepare, errno()));
538                    }
539                    if libc::chdir(base.as_ptr()) != 0 {
540                        return Err((MountStep::PivotPrepare, errno()));
541                    }
542                    if libc::syscall(
543                        libc::SYS_pivot_root,
544                        c".".as_ptr(),
545                        OLD_ROOT_NAME_C.as_ptr(),
546                    ) != 0
547                    {
548                        return Err((MountStep::PivotRoot, errno()));
549                    }
550                    if libc::chdir(c"/".as_ptr()) != 0 {
551                        return Err((MountStep::PivotRoot, errno()));
552                    }
553                    if libc::umount2(OLD_ROOT_PIVOTED_C.as_ptr(), libc::MNT_DETACH) != 0 {
554                        return Err((MountStep::DetachOldRoot, errno()));
555                    }
556                }
557            }
558            Ok(())
559        }
560    }
561
562    /// `mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)`.
563    ///
564    /// # Safety
565    /// Post-`fork()` child context only.
566    unsafe fn make_private() -> libc::c_int {
567        // SAFETY: every pointer is null or a static C string.
568        unsafe {
569            libc::mount(
570                std::ptr::null(),
571                c"/".as_ptr(),
572                std::ptr::null(),
573                libc::MS_REC | libc::MS_PRIVATE,
574                std::ptr::null(),
575            )
576        }
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::network_namespace::{encode_step_record, CreationStep};
584
585    fn created() -> NamespaceReading {
586        NamespaceReading {
587            network: Ok(()),
588            mount: MountAttempt::Created,
589            sealed_root: MountAttempt::Created,
590            userns_grant: UsernsGrant::Withheld,
591            attachment: ProfileAttachment::Unconfined,
592        }
593    }
594
595    fn failed(step: MountStep, errno: i32) -> MountAttempt {
596        MountAttempt::Failed(MountAttemptError::Step { step, errno })
597    }
598
599    #[test]
600    fn every_record_round_trips_and_an_unknown_code_is_none() {
601        assert_eq!(
602            decode_record(&encode_step_record(0, 0)),
603            Some(MountAttempt::Created)
604        );
605        for step in MountStep::ALL {
606            assert_eq!(
607                decode_record(&encode_step_record(step as u32, libc::EPERM)),
608                Some(failed(step, libc::EPERM))
609            );
610        }
611        assert_eq!(decode_record(&encode_step_record(99, 0)), None);
612    }
613
614    #[test]
615    fn every_step_names_its_own_call() {
616        let mut seen = std::collections::HashSet::new();
617        for step in MountStep::ALL {
618            assert!(
619                seen.insert(step.describe()),
620                "{step:?} repeats a description"
621            );
622        }
623        assert_eq!(MountStep::UnshareMount.describe(), "unshare(CLONE_NEWNS)");
624        assert_eq!(MountStep::PivotRoot.describe(), "pivot_root(2)");
625    }
626
627    #[test]
628    fn the_questions_follow_the_attempts_and_never_the_readings() {
629        for grant in UsernsGrant::ALL {
630            let reading = NamespaceReading {
631                userns_grant: *grant,
632                ..created()
633            };
634            assert!(reading.mount_namespace_created());
635            assert!(reading.sealed_root_created());
636            assert!(reading.user_mount_namespace_usable());
637            assert_eq!(reading.egress_namespace_failure(), None);
638            assert_eq!(reading.first_failure(), None);
639        }
640    }
641
642    #[test]
643    fn a_network_failure_creates_nothing_after_it() {
644        let reading = NamespaceReading {
645            network: Err(NamespaceAttemptError::Step {
646                step: CreationStep::Unshare,
647                errno: libc::EPERM,
648            }),
649            mount: MountAttempt::NotAttempted,
650            sealed_root: MountAttempt::NotAttempted,
651            ..created()
652        };
653        assert!(!reading.mount_namespace_created());
654        assert!(!reading.sealed_root_created());
655        assert!(!reading.user_mount_namespace_usable());
656        assert_eq!(
657            reading.first_failure().as_deref(),
658            cfg!(target_os = "linux")
659                .then_some("unshare(CLONE_NEWUSER | CLONE_NEWNET) failed with EPERM")
660        );
661    }
662
663    #[test]
664    fn the_user_mount_question_needs_the_nested_namespace_its_maps_and_propagation() {
665        for (step, usable) in [
666            (MountStep::JoinSession, false),
667            (MountStep::UnshareUserMount, false),
668            (MountStep::IdentityMaps, false),
669            (MountStep::MakePrivateNested, false),
670            (MountStep::Tmpfs, true),
671            (MountStep::PivotPrepare, true),
672            (MountStep::PivotRoot, true),
673            (MountStep::DetachOldRoot, true),
674        ] {
675            let reading = NamespaceReading {
676                sealed_root: failed(step, libc::EPERM),
677                ..created()
678            };
679            assert_eq!(reading.user_mount_namespace_usable(), usable, "{step:?}");
680            assert!(!reading.sealed_root_created());
681            assert!(reading.mount_namespace_created());
682        }
683        let reading = NamespaceReading {
684            sealed_root: MountAttempt::Failed(MountAttemptError::Rehearsal("x".to_string())),
685            ..created()
686        };
687        assert!(!reading.user_mount_namespace_usable());
688    }
689
690    #[test]
691    fn off_linux_nothing_is_created_and_nothing_is_refused() {
692        let reading = NamespaceReading::not_linux();
693        assert!(!reading.mount_namespace_created());
694        assert!(!reading.sealed_root_created());
695        assert!(!reading.user_mount_namespace_usable());
696        assert!(reading.mount.failure_summary().is_none());
697        assert!(reading.sealed_root.failure_summary().is_none());
698        #[cfg(not(target_os = "linux"))]
699        assert_eq!(probe_namespaces(), NamespaceReading::not_linux());
700    }
701
702    #[test]
703    fn a_failed_attempt_summarises_as_its_call_and_errno() {
704        assert_eq!(
705            failed(MountStep::MakePrivate, libc::EACCES)
706                .failure_summary()
707                .as_deref(),
708            Some("making / MS_REC|MS_PRIVATE failed with EACCES")
709        );
710        assert_eq!(
711            probe_failure_sentence(MountStep::PivotRoot.describe(), libc::EPERM),
712            "The probe's pivot_root(2) failed with EPERM (Operation not permitted)."
713        );
714    }
715
716    #[cfg(target_os = "linux")]
717    const INNER_RUN_ENV: &str = "MURMUR_NAMESPACE_PROBE_UNCHANGED_INNER";
718
719    /// The probe runs every attempt in a child: the calling process keeps its namespaces, and no
720    /// descriptor it opened outlives it.
721    ///
722    /// `/proc/self/fd` counts every thread's descriptors, so the count is exact only with no
723    /// other test running: the check runs in a single-threaded re-exec of this binary.
724    #[cfg(target_os = "linux")]
725    #[test]
726    fn the_probe_leaves_the_calling_process_unchanged() {
727        let exe = std::env::current_exe().expect("test binary path");
728        let output = std::process::Command::new(exe)
729            .args([
730                "--exact",
731                "namespace_probe::tests::inner_the_probe_leaves_the_calling_process_unchanged",
732                "--ignored",
733                "--test-threads=1",
734                "--nocapture",
735            ])
736            .env(INNER_RUN_ENV, "1")
737            .output()
738            .expect("re-exec the test binary for the single-threaded inner run");
739        let stdout = String::from_utf8_lossy(&output.stdout);
740        let stderr = String::from_utf8_lossy(&output.stderr);
741        assert!(
742            output.status.success(),
743            "inner probe run failed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
744        );
745        assert!(
746            stdout.contains("1 passed"),
747            "inner run did not execute the probe test\n--- stdout ---\n{stdout}"
748        );
749    }
750
751    #[cfg(target_os = "linux")]
752    #[test]
753    #[ignore = "run by the_probe_leaves_the_calling_process_unchanged"]
754    fn inner_the_probe_leaves_the_calling_process_unchanged() {
755        if std::env::var(INNER_RUN_ENV).is_err() {
756            return;
757        }
758        let namespaces = || {
759            ["user", "net", "mnt"].map(|ns| std::fs::read_link(format!("/proc/self/ns/{ns}")).ok())
760        };
761        let fds = || {
762            std::fs::read_dir("/proc/self/fd")
763                .map(|dir| dir.count())
764                .ok()
765        };
766        let before = (namespaces(), fds());
767        let reading = probe_namespaces();
768        assert_eq!((namespaces(), fds()), before, "{reading:?}");
769        if reading.network.is_err() {
770            assert_eq!(reading.mount, MountAttempt::NotAttempted);
771            assert_eq!(reading.sealed_root, MountAttempt::NotAttempted);
772        }
773    }
774}