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