Skip to main content

capsule_runtime/
temp_dir.rs

1//! The session's temporary folder: `tmp/` in the session's records directory, which every shell
2//! command, plan shell step and native tool reaches as `$TMPDIR`, `$TMP` and `$TEMP`, and on Linux
3//! as a literal `/tmp` wherever the host can give a command a private mount namespace.
4//!
5//! ```text
6//! ~/.murmur/records/<workdir root>/<session-id>/
7//! └── tmp/        0700, created at staging, writable by the session's commands
8//! ```
9//!
10//! One folder per session, never inside the workdir and never shared between sessions. Its bytes
11//! count against `capabilities.resources.workdir_max_bytes` with the workdir's
12//! ([`crate::resources::WorkdirGuard`]), and `trace.retain` removes it with the records directory.
13//! The runtime itself writes nothing in it.
14//!
15//! [`temp_mapping`] is the one decision of whether a literal `/tmp` reaches the folder. Staging
16//! makes it once, and the session's [`crate::containment::ScopeReport`] and its enforcement both
17//! read that answer; `mur run --explain-scope` and `mur doctor` make it from the same inputs.
18//!
19//! | Applied tier | Mount namespace usable | Literal `/tmp` | `TMPDIR` |
20//! |---|---|---|---|
21//! | `KernelSealed` | required by the tier | the composed root binds the folder | `/tmp` |
22//! | `KernelFull`, `KernelSeccompOnly` | yes | a private mount namespace per command binds it | `/tmp` |
23//! | `KernelFull` | no | not mapped; Landlock refuses it | the folder |
24//! | `KernelSeccompOnly` | no | not mapped; the host's `/tmp` | the folder |
25//! | `EnvironmentOnly` | — | not mapped; the host's `/tmp` | the folder |
26//! | any but `KernelSealed`, workdir is `/tmp` | — | not mapped | the folder |
27
28use std::path::{Path, PathBuf};
29
30use crate::sandbox::EnforcementTier;
31
32/// The folder's name beneath the session's records directory.
33pub const SESSION_TEMP_DIR_NAME: &str = "tmp";
34
35/// The path a mapped command reaches the folder at, and the value of `TMPDIR` there.
36pub const TMP_PATH: &str = "/tmp";
37
38/// The variables naming the folder in every capsule command's environment. Runtime-owned: no
39/// manifest key, override or host value can set them.
40pub const TEMP_DIR_ENV: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
41
42/// What every mapping failure's message starts with.
43pub const TMP_MAP_FAILURE_PREFIX: &str =
44    "sandbox: could not map /tmp onto this session's temporary folder:";
45
46/// How a literal `/tmp` reaches the session's temporary folder, or why it does not.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum TempMapping {
49    /// The sealed composed root binds the folder at `/tmp`.
50    ComposedRoot,
51    /// Each command enters a private mount namespace in which the folder is bound over `/tmp`.
52    MountNamespace,
53    /// A literal `/tmp` is not the folder; `TMPDIR` names the folder's absolute path.
54    NotMapped(NotMappedReason),
55}
56
57/// Why a session's `/tmp` is not mapped onto its temporary folder.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum NotMappedReason {
60    /// No kernel containment at all: macOS and every non-Linux host.
61    NoKernelContainment,
62    /// Neither a mount namespace nor Landlock.
63    NoMountNamespaceNoLandlock,
64    /// Landlock without a usable mount namespace.
65    NoMountNamespace,
66    /// The accessible workdir is `/tmp` itself, which a bind over `/tmp` would hide.
67    WorkdirIsTmp,
68}
69
70impl TempMapping {
71    /// Whether a literal `/tmp` is the folder.
72    #[must_use]
73    pub fn is_mapped(self) -> bool {
74        !matches!(self, Self::NotMapped(_))
75    }
76
77    /// The mechanism's wire name, `null` in the report when not mapped.
78    #[must_use]
79    pub fn mapped_by(self) -> Option<&'static str> {
80        match self {
81            Self::ComposedRoot => Some("composed-root"),
82            Self::MountNamespace => Some("mount-namespace"),
83            Self::NotMapped(_) => None,
84        }
85    }
86
87    /// The value of `TMPDIR`, `TMP` and `TEMP` for a session whose folder is at `folder`.
88    #[must_use]
89    pub fn tmpdir_value(self, folder: &Path) -> String {
90        if self.is_mapped() {
91            TMP_PATH.to_string()
92        } else {
93            folder.to_string_lossy().into_owned()
94        }
95    }
96}
97
98impl NotMappedReason {
99    /// The reason in words an operator can act on: `not_mapped_because` in the report.
100    #[must_use]
101    pub fn describe(self) -> &'static str {
102        match self {
103            Self::NoKernelContainment => {
104                "this host has no kernel containment for capsule commands, so a literal /tmp path \
105                 is the host's own /tmp; TMPDIR names the session's temporary folder"
106            }
107            Self::NoMountNamespaceNoLandlock => {
108                "this host can give capsule commands neither a private mount namespace nor \
109                 Landlock, so a literal /tmp path is the host's own /tmp; TMPDIR names the \
110                 session's temporary folder"
111            }
112            Self::NoMountNamespace => {
113                "this host cannot give capsule commands a private mount namespace, so a literal \
114                 /tmp path is refused; TMPDIR names the session's temporary folder"
115            }
116            Self::WorkdirIsTmp => {
117                "the workdir is /tmp itself, which mapping /tmp would hide; TMPDIR names the \
118                 session's temporary folder"
119            }
120        }
121    }
122}
123
124/// The host's `/tmp`, canonicalized, or `/tmp` itself when it cannot be resolved.
125pub(crate) fn canonical_tmp() -> PathBuf {
126    std::fs::canonicalize(TMP_PATH).unwrap_or_else(|_| PathBuf::from(TMP_PATH))
127}
128
129/// The mapping the tier `applied` gives with no usable mount namespace: the composed root's on
130/// `KernelSealed`, and unmapped on every other tier. What an enforcement that was never handed
131/// its session's [`temp_mapping`] answer installs.
132pub(crate) fn tier_temp_mapping(applied: EnforcementTier) -> TempMapping {
133    match applied {
134        EnforcementTier::KernelSealed => TempMapping::ComposedRoot,
135        EnforcementTier::KernelFull => TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
136        EnforcementTier::KernelSeccompOnly => {
137            TempMapping::NotMapped(NotMappedReason::NoMountNamespaceNoLandlock)
138        }
139        EnforcementTier::EnvironmentOnly => {
140            TempMapping::NotMapped(NotMappedReason::NoKernelContainment)
141        }
142    }
143}
144
145/// Whether a literal `/tmp` reaches the session's temporary folder on the tier `applied`, given
146/// whether the session's [`crate::sandbox::HostProbe`] found a usable mount namespace and the
147/// workdir the session's commands run in.
148///
149/// Pure apart from canonicalizing `accessible_workdir` and `/tmp`; a workdir that does not exist
150/// yet is compared as given.
151pub(crate) fn temp_mapping(
152    applied: EnforcementTier,
153    mount_namespace_usable: bool,
154    accessible_workdir: &Path,
155) -> TempMapping {
156    let workdir_is_tmp = || {
157        let workdir = std::fs::canonicalize(accessible_workdir)
158            .unwrap_or_else(|_| accessible_workdir.to_path_buf());
159        workdir == canonical_tmp()
160    };
161    match applied {
162        EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly if workdir_is_tmp() => {
163            TempMapping::NotMapped(NotMappedReason::WorkdirIsTmp)
164        }
165        EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly
166            if mount_namespace_usable =>
167        {
168            TempMapping::MountNamespace
169        }
170        _ => tier_temp_mapping(applied),
171    }
172}
173
174/// The paths beneath the host's `/tmp` a mapped command still needs, each re-exposed at its own
175/// path inside the folder bound over `/tmp`, with whether its bind is read-only: `writable` (the
176/// workdirs) read-write, `read_only` (the runtime and records directories and the Landlock grant
177/// paths) read-only.
178///
179/// Exact paths, never a whole entry of `/tmp`: Landlock accumulates rights up the path walk,
180/// across mount boundaries, so everything bound beneath the folder also holds the folder's
181/// rights. A read-write bind is the workdir, whose own rule is that same right-set; every other
182/// bind is read-only, so those rights write nothing there. Nothing else of the host's `/tmp` is
183/// visible.
184///
185/// Each path is canonicalized first; one that cannot be, or that does not lie strictly beneath
186/// `canonical_tmp`, is skipped. The result is in path order, so a bind is made before any bind
187/// beneath it. A path beneath a read-write path is dropped, since that bind already carries it;
188/// a read-write path beneath a read-only one is kept and bound over it. A path given both ways is
189/// read-write.
190#[cfg(target_os = "linux")]
191pub(crate) fn reexposed_paths<'a>(
192    canonical_tmp: &Path,
193    writable: impl IntoIterator<Item = &'a Path>,
194    read_only: impl IntoIterator<Item = &'a Path>,
195) -> Vec<(PathBuf, bool)> {
196    let beneath_tmp = |path: &Path| {
197        std::fs::canonicalize(path)
198            .ok()
199            .filter(|canonical| canonical != canonical_tmp && canonical.starts_with(canonical_tmp))
200    };
201    let mut wanted: Vec<(PathBuf, bool)> = Vec::new();
202    for (path, is_read_only) in writable
203        .into_iter()
204        .map(|path| (path, false))
205        .chain(read_only.into_iter().map(|path| (path, true)))
206    {
207        let Some(canonical) = beneath_tmp(path) else {
208            continue;
209        };
210        match wanted.iter_mut().find(|(seen, _)| *seen == canonical) {
211            Some((_, seen_read_only)) => *seen_read_only &= is_read_only,
212            None => wanted.push((canonical, is_read_only)),
213        }
214    }
215    wanted.sort();
216    let mut kept: Vec<(PathBuf, bool)> = Vec::new();
217    for (path, is_read_only) in wanted {
218        let under_writable = kept
219            .iter()
220            .any(|(outer, outer_read_only)| !outer_read_only && path.starts_with(outer));
221        if !under_writable {
222            kept.push((path, is_read_only));
223        }
224    }
225    kept
226}
227
228#[cfg(all(test, target_os = "linux"))]
229pub(crate) use linux::TmpMapFailure;
230#[cfg(target_os = "linux")]
231pub(crate) use linux::{map_private_tmp, TmpMapSpec};
232
233#[cfg(target_os = "linux")]
234#[allow(unsafe_code)]
235mod linux {
236    use std::ffi::CString;
237    use std::os::unix::ffi::OsStrExt;
238    use std::path::{Path, PathBuf};
239    use std::sync::atomic::{AtomicI32, Ordering};
240
241    use crate::sealed::{open_verified_dir, proc_fd_path, remount_read_only, PROC_FD_PATH_LEN};
242
243    /// One path beneath the host's `/tmp` a mapped command still needs, re-exposed at its own
244    /// path.
245    struct Reexposure {
246        /// The path on the host, `/tmp/<components>`.
247        source: CString,
248        /// Its components beneath `/tmp`, the mountpoint's path inside the folder bound over it.
249        components: Vec<CString>,
250        /// Whether the path is a directory; a mountpoint for anything else is an empty file.
251        directory: bool,
252        /// Whether the bind is remounted read-only.
253        read_only: bool,
254        /// The source's `O_PATH` descriptor, taken in the child after `unshare(CLONE_NEWNS)` and
255        /// before `/tmp` is covered. `-1` until then.
256        fd: AtomicI32,
257    }
258
259    /// Everything [`map_private_tmp`] needs, resolved in the parent so the child allocates
260    /// nothing.
261    pub(crate) struct TmpMapSpec {
262        /// The folder's host path, and the `(st_dev, st_ino)` of the parent's descriptor on it,
263        /// which the child's open must reach.
264        folder: CString,
265        folder_display: PathBuf,
266        folder_identity: (u64, u64),
267        /// Canonical `/tmp`, the bind's target.
268        tmp: CString,
269        tmp_display: PathBuf,
270        reexposures: Vec<Reexposure>,
271        /// The command's working directory, re-entered once `/tmp` is covered.
272        workdir: CString,
273        workdir_display: PathBuf,
274    }
275
276    /// The step of [`map_private_tmp`] that failed, and its `errno`.
277    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
278    pub(crate) struct TmpMapFailure {
279        step: Step,
280        errno: i32,
281    }
282
283    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
284    enum Step {
285        Unshare,
286        MakePrivate,
287        OpenSource(usize),
288        OpenFolder,
289        BindFolder,
290        OpenTmp,
291        CreateMountpoint(usize),
292        OpenMountpoint(usize),
293        BindReexposure(usize),
294        RemountReadOnly(usize),
295        Chdir,
296    }
297
298    impl TmpMapFailure {
299        fn at(step: Step) -> Self {
300            Self {
301                step,
302                errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
303            }
304        }
305
306        /// A failure at the first step with `errno`, for the forced-failure test seam.
307        #[cfg(test)]
308        pub(crate) fn forced(errno: i32) -> Self {
309            Self {
310                step: Step::Unshare,
311                errno,
312            }
313        }
314    }
315
316    impl TmpMapSpec {
317        /// The spec for one command: `folder` at `folder_identity`, bound over `canonical_tmp`,
318        /// with each of `reexposed` (from [`super::reexposed_paths`]) bound back at its own
319        /// path, read-only where it says so, and `workdir` re-entered afterwards.
320        pub(crate) fn new(
321            folder: &Path,
322            folder_identity: (u64, u64),
323            canonical_tmp: &Path,
324            reexposed: &[(PathBuf, bool)],
325            workdir: &Path,
326        ) -> Result<Self, String> {
327            let c_path = |path: &Path| {
328                CString::new(path.as_os_str().as_bytes()).map_err(|_| {
329                    format!(
330                        "{} {} holds a NUL byte",
331                        super::TMP_MAP_FAILURE_PREFIX,
332                        path.display()
333                    )
334                })
335            };
336            let mut reexposures = Vec::with_capacity(reexposed.len());
337            for (source, read_only) in reexposed {
338                let Ok(beneath) = source.strip_prefix(canonical_tmp) else {
339                    continue;
340                };
341                let components = beneath
342                    .components()
343                    .map(|component| c_path(Path::new(component.as_os_str())))
344                    .collect::<Result<Vec<_>, _>>()?;
345                if components.is_empty() {
346                    continue;
347                }
348                reexposures.push(Reexposure {
349                    source: c_path(source)?,
350                    components,
351                    directory: std::fs::symlink_metadata(source)
352                        .map(|meta| meta.is_dir())
353                        .unwrap_or(true),
354                    read_only: *read_only,
355                    fd: AtomicI32::new(-1),
356                });
357            }
358            Ok(Self {
359                folder: c_path(folder)?,
360                folder_display: folder.to_path_buf(),
361                folder_identity,
362                tmp: c_path(canonical_tmp)?,
363                tmp_display: canonical_tmp.to_path_buf(),
364                reexposures,
365                workdir: c_path(workdir)?,
366                workdir_display: workdir.to_path_buf(),
367            })
368        }
369
370        /// The host paths re-exposed beneath `/tmp`, in bind order, each with whether it is
371        /// read-only.
372        #[cfg(test)]
373        pub(crate) fn reexposed_sources(&self) -> Vec<(String, bool)> {
374            self.reexposures
375                .iter()
376                .map(|entry| (entry.source.to_string_lossy().into_owned(), entry.read_only))
377                .collect()
378        }
379
380        /// `failure` as the message that reaches the operator:
381        /// `sandbox: could not map /tmp onto this session's temporary folder: <step> failed:
382        /// <errno text>`.
383        pub(crate) fn describe(&self, failure: TmpMapFailure) -> String {
384            let tmp = self.tmp_display.display();
385            let entry = |index: usize| {
386                self.reexposures
387                    .get(index)
388                    .map(|entry| {
389                        entry
390                            .components
391                            .iter()
392                            .map(|component| component.to_string_lossy().into_owned())
393                            .collect::<Vec<_>>()
394                            .join("/")
395                    })
396                    .unwrap_or_default()
397            };
398            let source = |index: usize| {
399                self.reexposures
400                    .get(index)
401                    .map(|entry| entry.source.to_string_lossy().into_owned())
402                    .unwrap_or_default()
403            };
404            let step = match failure.step {
405                Step::Unshare => "unshare(CLONE_NEWNS)".to_string(),
406                Step::MakePrivate => "making / private (MS_REC|MS_PRIVATE)".to_string(),
407                Step::OpenSource(index) => format!("opening {} to re-expose it", source(index)),
408                Step::OpenFolder => format!(
409                    "opening the temporary folder {}",
410                    self.folder_display.display()
411                ),
412                Step::BindFolder => format!(
413                    "binding the temporary folder {} over {tmp}",
414                    self.folder_display.display()
415                ),
416                Step::OpenTmp => format!("opening the new {tmp}"),
417                Step::CreateMountpoint(index) => {
418                    format!("creating the mountpoint {tmp}/{}", entry(index))
419                }
420                Step::OpenMountpoint(index) => {
421                    format!("opening the mountpoint {tmp}/{}", entry(index))
422                }
423                Step::BindReexposure(index) => {
424                    format!("binding {} back at its own path", source(index))
425                }
426                Step::RemountReadOnly(index) => {
427                    format!("remounting {} read-only", source(index))
428                }
429                Step::Chdir => format!(
430                    "entering the working directory {}",
431                    self.workdir_display.display()
432                ),
433            };
434            format!(
435                "{} {step} failed: {}",
436                super::TMP_MAP_FAILURE_PREFIX,
437                std::io::Error::from_raw_os_error(failure.errno)
438            )
439        }
440    }
441
442    /// Closes `fd`, leaving `errno` as the last call before it left it.
443    ///
444    /// # Safety
445    /// `fd` is open and owned by the caller. Allocation-free; callable post-`fork()`, pre-exec.
446    unsafe fn close_keeping_errno(fd: libc::c_int) {
447        let errno = *libc::__errno_location();
448        libc::close(fd);
449        *libc::__errno_location() = errno;
450    }
451
452    /// `mount --bind` of the descriptor `source` onto the descriptor `target`, recursively, by
453    /// their `/proc/self/fd` paths.
454    ///
455    /// # Safety
456    /// Both descriptors are open. Allocation-free; callable post-`fork()`, pre-exec.
457    unsafe fn bind_fd_onto_fd(source: libc::c_int, target: libc::c_int) -> Result<(), ()> {
458        let mut source_path = [0u8; PROC_FD_PATH_LEN];
459        let mut target_path = [0u8; PROC_FD_PATH_LEN];
460        proc_fd_path(source, &mut source_path)?;
461        proc_fd_path(target, &mut target_path)?;
462        if libc::mount(
463            source_path.as_ptr() as *const libc::c_char,
464            target_path.as_ptr() as *const libc::c_char,
465            std::ptr::null(),
466            libc::MS_BIND | libc::MS_REC,
467            std::ptr::null(),
468        ) != 0
469        {
470            return Err(());
471        }
472        Ok(())
473    }
474
475    /// Whether the descriptor `fd` is a directory (`directory`) or a regular file.
476    ///
477    /// # Safety
478    /// Allocation-free; callable post-`fork()`, pre-exec.
479    unsafe fn fd_kind_is(fd: libc::c_int, directory: bool) -> bool {
480        let mut stat: libc::stat = std::mem::zeroed();
481        if libc::fstat(fd, &mut stat) != 0 {
482            return false;
483        }
484        let kind = stat.st_mode & libc::S_IFMT;
485        if directory {
486            kind == libc::S_IFDIR
487        } else {
488            kind == libc::S_IFREG
489        }
490    }
491
492    /// Closes every re-exposure source descriptor taken so far.
493    ///
494    /// # Safety
495    /// Only in the process that took them, on [`map_private_tmp`]'s terms.
496    unsafe fn close_sources(spec: &TmpMapSpec) {
497        for entry in &spec.reexposures {
498            let fd = entry.fd.swap(-1, Ordering::Relaxed);
499            if fd >= 0 {
500                close_keeping_errno(fd);
501            }
502        }
503    }
504
505    /// Gives the calling process a private mount namespace in which `spec`'s folder is bound over
506    /// `/tmp`, every re-exposed entry of the host's `/tmp` is bound back at its own path, and the
507    /// working directory is re-entered.
508    ///
509    /// # Safety
510    /// Post-`fork()`, pre-exec only, with every capability in the caller's user namespace (the
511    /// session's, after `join_session_netns`) and before the seccomp filter that denies `unshare`
512    /// and `mount`. Allocation-free: every path and buffer was built in the parent.
513    pub(crate) unsafe fn map_private_tmp(spec: &TmpMapSpec) -> Result<(), TmpMapFailure> {
514        // 1. A mount namespace of the command's own, owned by the session's user namespace.
515        if libc::unshare(libc::CLONE_NEWNS) != 0 {
516            return Err(TmpMapFailure::at(Step::Unshare));
517        }
518        // 2. Nothing mounted from here on propagates anywhere.
519        if libc::mount(
520            std::ptr::null(),
521            c"/".as_ptr(),
522            std::ptr::null(),
523            libc::MS_REC | libc::MS_PRIVATE,
524            std::ptr::null(),
525        ) != 0
526        {
527            return Err(TmpMapFailure::at(Step::MakePrivate));
528        }
529        // 3. Every re-exposure source, while `/tmp` still shows the host's tree. Opened in this
530        //    namespace, since a bind's source must be a mount in the caller's own.
531        for (index, entry) in spec.reexposures.iter().enumerate() {
532            let fd = libc::open(
533                entry.source.as_ptr(),
534                libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC,
535            );
536            if fd < 0 {
537                let failure = TmpMapFailure::at(Step::OpenSource(index));
538                close_sources(spec);
539                return Err(failure);
540            }
541            entry.fd.store(fd, Ordering::Relaxed);
542            if !fd_kind_is(fd, entry.directory) {
543                close_sources(spec);
544                *libc::__errno_location() = libc::ESTALE;
545                return Err(TmpMapFailure::at(Step::OpenSource(index)));
546            }
547        }
548        // 4. The folder, verified against the parent's descriptor, bound over `/tmp`. A link
549        //    swapped onto its path fails here with `ESTALE` rather than redirecting the bind.
550        let folder = match open_verified_dir(&spec.folder, spec.folder_identity) {
551            Ok(fd) => fd,
552            Err(()) => {
553                let failure = TmpMapFailure::at(Step::OpenFolder);
554                close_sources(spec);
555                return Err(failure);
556            }
557        };
558        let tmp_target = libc::open(
559            spec.tmp.as_ptr(),
560            libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
561        );
562        let bound = tmp_target >= 0 && bind_fd_onto_fd(folder, tmp_target).is_ok();
563        let failure = (!bound).then(|| TmpMapFailure::at(Step::BindFolder));
564        close_keeping_errno(folder);
565        if tmp_target >= 0 {
566            close_keeping_errno(tmp_target);
567        }
568        if let Some(failure) = failure {
569            close_sources(spec);
570            return Err(failure);
571        }
572        // 5. Each re-exposure, in path order: its mountpoint inside the new `/tmp`, every
573        //    directory to it created or opened with no link followed, the source bound onto it,
574        //    and a read-only one remounted so.
575        let new_tmp = libc::open(
576            spec.tmp.as_ptr(),
577            libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
578        );
579        if new_tmp < 0 {
580            let failure = TmpMapFailure::at(Step::OpenTmp);
581            close_sources(spec);
582            return Err(failure);
583        }
584        let result = reexpose_all(spec, new_tmp);
585        close_keeping_errno(new_tmp);
586        close_sources(spec);
587        result?;
588        // 6. The working directory std entered before this closure ran lies on the covered
589        //    `/tmp` when the workdir is beneath it; entering it again by path lands on the
590        //    visible mount.
591        if libc::chdir(spec.workdir.as_ptr()) != 0 {
592            return Err(TmpMapFailure::at(Step::Chdir));
593        }
594        Ok(())
595    }
596
597    /// Opens the directory `name` in `parent` with no link followed, creating it first when it
598    /// is absent. `-1` on failure, with `errno` set.
599    ///
600    /// # Safety
601    /// `parent` is open. Allocation-free; callable post-`fork()`, pre-exec.
602    unsafe fn open_or_create_dir(parent: libc::c_int, name: &CString) -> libc::c_int {
603        let flags = libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
604        let fd = libc::openat(parent, name.as_ptr(), flags);
605        if fd >= 0 || *libc::__errno_location() != libc::ENOENT {
606            return fd;
607        }
608        if libc::mkdirat(parent, name.as_ptr(), 0o700) != 0
609            && *libc::__errno_location() != libc::EEXIST
610        {
611            return -1;
612        }
613        libc::openat(parent, name.as_ptr(), flags)
614    }
615
616    /// Opens the regular file `name` in `parent` with no link followed, creating it empty first
617    /// when it is absent. `-1` on failure, with `errno` set.
618    ///
619    /// # Safety
620    /// `parent` is open. Allocation-free; callable post-`fork()`, pre-exec.
621    unsafe fn open_or_create_file(parent: libc::c_int, name: &CString) -> libc::c_int {
622        let flags = libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC;
623        let fd = libc::openat(parent, name.as_ptr(), flags);
624        if fd >= 0 || *libc::__errno_location() != libc::ENOENT {
625            return fd;
626        }
627        let created = libc::openat(
628            parent,
629            name.as_ptr(),
630            libc::O_CREAT | libc::O_EXCL | libc::O_WRONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
631            0o600,
632        );
633        if created >= 0 {
634            libc::close(created);
635        } else if *libc::__errno_location() != libc::EEXIST {
636            return -1;
637        }
638        libc::openat(parent, name.as_ptr(), flags)
639    }
640
641    /// Remounts the bind whose root `fd` names read-only and `nosuid`, keeping the flags its
642    /// source mount locks.
643    ///
644    /// # Safety
645    /// `fd` is open on the bind's root. Allocation-free; callable post-`fork()`, pre-exec.
646    unsafe fn remount_fd_read_only(fd: libc::c_int) -> Result<(), ()> {
647        let mut path = [0u8; PROC_FD_PATH_LEN];
648        proc_fd_path(fd, &mut path)?;
649        let path = std::ffi::CStr::from_bytes_until_nul(&path).map_err(|_| ())?;
650        remount_read_only(path)
651    }
652
653    /// Step 5 of [`map_private_tmp`] for every re-exposure, beneath the new `/tmp` at `new_tmp`.
654    ///
655    /// # Safety
656    /// On [`map_private_tmp`]'s terms, after step 3 took every source descriptor.
657    unsafe fn reexpose_all(spec: &TmpMapSpec, new_tmp: libc::c_int) -> Result<(), TmpMapFailure> {
658        for (index, entry) in spec.reexposures.iter().enumerate() {
659            let Some((last, leading)) = entry.components.split_last() else {
660                continue;
661            };
662            // The directories leading to the mountpoint, each reached from the one before.
663            let mut parent = new_tmp;
664            for component in leading {
665                let next = open_or_create_dir(parent, component);
666                let failure = (next < 0).then(|| TmpMapFailure::at(Step::CreateMountpoint(index)));
667                if parent != new_tmp {
668                    close_keeping_errno(parent);
669                }
670                if let Some(failure) = failure {
671                    return Err(failure);
672                }
673                parent = next;
674            }
675            let close_parent = |parent: libc::c_int| {
676                if parent != new_tmp {
677                    // SAFETY: `parent` is a descriptor this loop opened and has not closed.
678                    unsafe { close_keeping_errno(parent) };
679                }
680            };
681            let mountpoint = if entry.directory {
682                open_or_create_dir(parent, last)
683            } else {
684                open_or_create_file(parent, last)
685            };
686            if mountpoint < 0 {
687                let failure = TmpMapFailure::at(Step::CreateMountpoint(index));
688                close_parent(parent);
689                return Err(failure);
690            }
691            if !fd_kind_is(mountpoint, entry.directory) {
692                libc::close(mountpoint);
693                close_parent(parent);
694                *libc::__errno_location() = libc::ENOTDIR;
695                return Err(TmpMapFailure::at(Step::OpenMountpoint(index)));
696            }
697            let bound = bind_fd_onto_fd(entry.fd.load(Ordering::Relaxed), mountpoint);
698            let failure = bound
699                .is_err()
700                .then(|| TmpMapFailure::at(Step::BindReexposure(index)));
701            close_keeping_errno(mountpoint);
702            if let Some(failure) = failure {
703                close_parent(parent);
704                return Err(failure);
705            }
706            if entry.read_only {
707                // The bind's own root, reached through the name now covered by it.
708                let root = libc::openat(
709                    parent,
710                    last.as_ptr(),
711                    libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC,
712                );
713                let remounted = root >= 0 && remount_fd_read_only(root).is_ok();
714                let failure = (!remounted).then(|| TmpMapFailure::at(Step::RemountReadOnly(index)));
715                if root >= 0 {
716                    close_keeping_errno(root);
717                }
718                if let Some(failure) = failure {
719                    close_parent(parent);
720                    return Err(failure);
721                }
722            }
723            close_parent(parent);
724        }
725        Ok(())
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    /// Every row of the per-tier table, through the one decision function.
734    #[test]
735    fn temp_mapping_follows_the_per_tier_table() {
736        let elsewhere = Path::new("/nonexistent-murmur-temp-dir-workdir");
737        let tmp = canonical_tmp();
738        let rows = [
739            (
740                EnforcementTier::KernelSealed,
741                true,
742                elsewhere,
743                TempMapping::ComposedRoot,
744            ),
745            (
746                EnforcementTier::KernelSealed,
747                true,
748                tmp.as_path(),
749                TempMapping::ComposedRoot,
750            ),
751            (
752                EnforcementTier::KernelFull,
753                true,
754                elsewhere,
755                TempMapping::MountNamespace,
756            ),
757            (
758                EnforcementTier::KernelFull,
759                false,
760                elsewhere,
761                TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
762            ),
763            (
764                EnforcementTier::KernelSeccompOnly,
765                true,
766                elsewhere,
767                TempMapping::MountNamespace,
768            ),
769            (
770                EnforcementTier::KernelSeccompOnly,
771                false,
772                elsewhere,
773                TempMapping::NotMapped(NotMappedReason::NoMountNamespaceNoLandlock),
774            ),
775            (
776                EnforcementTier::EnvironmentOnly,
777                true,
778                elsewhere,
779                TempMapping::NotMapped(NotMappedReason::NoKernelContainment),
780            ),
781            (
782                EnforcementTier::EnvironmentOnly,
783                false,
784                elsewhere,
785                TempMapping::NotMapped(NotMappedReason::NoKernelContainment),
786            ),
787            (
788                EnforcementTier::KernelFull,
789                true,
790                tmp.as_path(),
791                TempMapping::NotMapped(NotMappedReason::WorkdirIsTmp),
792            ),
793            (
794                EnforcementTier::KernelSeccompOnly,
795                true,
796                Path::new(TMP_PATH),
797                TempMapping::NotMapped(NotMappedReason::WorkdirIsTmp),
798            ),
799        ];
800        for (tier, usable, workdir, expected) in rows {
801            assert_eq!(
802                temp_mapping(tier, usable, workdir),
803                expected,
804                "{tier:?}, mount namespace usable: {usable}, workdir {}",
805                workdir.display()
806            );
807        }
808    }
809
810    #[test]
811    fn tmpdir_is_tmp_when_mapped_and_the_folder_otherwise() {
812        let folder = Path::new("/home/u/.murmur/records/w/ses_1/tmp");
813        assert_eq!(TempMapping::ComposedRoot.tmpdir_value(folder), "/tmp");
814        assert_eq!(TempMapping::MountNamespace.tmpdir_value(folder), "/tmp");
815        for reason in [
816            NotMappedReason::NoKernelContainment,
817            NotMappedReason::NoMountNamespaceNoLandlock,
818            NotMappedReason::NoMountNamespace,
819            NotMappedReason::WorkdirIsTmp,
820        ] {
821            let mapping = TempMapping::NotMapped(reason);
822            assert_eq!(mapping.tmpdir_value(folder), folder.to_str().unwrap());
823            assert_eq!(mapping.mapped_by(), None);
824            assert!(!reason.describe().is_empty());
825        }
826        assert_eq!(TempMapping::ComposedRoot.mapped_by(), Some("composed-root"));
827        assert_eq!(
828            TempMapping::MountNamespace.mapped_by(),
829            Some("mount-namespace")
830        );
831    }
832
833    /// Every path strictly beneath `/tmp` is re-exposed at its own path, read-only unless it is
834    /// writable, once, in path order; one beneath a writable path is dropped, a writable one
835    /// beneath a read-only path is kept; `/tmp` itself, a path elsewhere and one that does not
836    /// exist contribute nothing.
837    #[cfg(target_os = "linux")]
838    #[test]
839    fn reexposed_paths_are_exact_and_read_only_unless_writable() {
840        let tmp = canonical_tmp();
841        let base = tempfile::tempdir_in(&tmp).unwrap();
842        let base = std::fs::canonicalize(base.path()).unwrap();
843        let workdir = base.join("work");
844        let session = workdir.join(".murmur").join("ses");
845        let records = base.join("records");
846        let inside_records = records.join("nested-work");
847        let grant = base.join("bin").join("tool");
848        for dir in [
849            &session,
850            &inside_records,
851            &grant.parent().unwrap().to_path_buf(),
852        ] {
853            std::fs::create_dir_all(dir).unwrap();
854        }
855        std::fs::write(&grant, b"x").unwrap();
856        let home = std::env::var_os("HOME").map(PathBuf::from).unwrap();
857        let absent = tmp.join("murmur-temp-dir-test-absent");
858
859        let paths = reexposed_paths(
860            &tmp,
861            [
862                workdir.as_path(),
863                session.as_path(),
864                inside_records.as_path(),
865            ],
866            [
867                records.as_path(),
868                grant.as_path(),
869                workdir.join(".murmur").as_path(),
870                tmp.as_path(),
871                home.as_path(),
872                absent.as_path(),
873                grant.as_path(),
874            ],
875        );
876        assert_eq!(
877            paths,
878            vec![
879                (grant.clone(), true),
880                (records.clone(), true),
881                (inside_records.clone(), false),
882                (workdir.clone(), false),
883            ]
884        );
885    }
886
887    #[cfg(target_os = "linux")]
888    #[test]
889    fn a_mapping_failure_names_its_step_and_errno() {
890        let spec = TmpMapSpec::new(
891            Path::new("/home/u/.murmur/records/w/ses_1/tmp"),
892            (1, 2),
893            Path::new("/tmp"),
894            &[],
895            Path::new("/work"),
896        )
897        .unwrap();
898        let message = spec.describe(TmpMapFailure::forced(libc::EPERM));
899        assert!(message.starts_with(TMP_MAP_FAILURE_PREFIX), "{message}");
900        assert!(
901            message.contains("unshare(CLONE_NEWNS) failed: "),
902            "{message}"
903        );
904        assert!(
905            message.contains(&std::io::Error::from_raw_os_error(libc::EPERM).to_string()),
906            "{message}"
907        );
908    }
909}