Skip to main content

bootc_internal_mount/
mount.rs

1//! Helpers for interacting with mountpoints
2
3use std::{
4    fs,
5    mem::MaybeUninit,
6    os::fd::{AsFd, OwnedFd},
7    process::Command,
8};
9
10use anyhow::{Context, Result, anyhow};
11use bootc_utils::CommandRunExt;
12use camino::Utf8Path;
13use cap_std_ext::{cap_std::fs::Dir, cmdext::CapStdExtCommandExt};
14use fn_error_context::context;
15use rustix::{
16    mount::{MoveMountFlags, OpenTreeFlags},
17    net::{
18        AddressFamily, RecvFlags, SendAncillaryBuffer, SendAncillaryMessage, SendFlags,
19        SocketFlags, SocketType,
20    },
21    process::WaitOptions,
22    thread::Pid,
23};
24use serde::Deserialize;
25
26/// Temporary mount management with automatic cleanup.
27pub mod tempmount;
28
29/// Well known identifier for pid 1
30pub const PID1: Pid = const {
31    match Pid::from_raw(1) {
32        Some(v) => v,
33        None => panic!("Expected to parse pid1"),
34    }
35};
36
37/// Deserialized information about a mounted filesystem from `findmnt`.
38#[derive(Deserialize, Debug)]
39#[serde(rename_all = "kebab-case")]
40#[allow(dead_code)]
41pub struct Filesystem {
42    // Note if you add an entry to this list, you need to change the --output invocation below too
43    /// The source device or path.
44    pub source: String,
45    /// The mount target path.
46    pub target: String,
47    /// Major:minor device numbers.
48    #[serde(rename = "maj:min")]
49    pub maj_min: String,
50    /// The filesystem type (e.g. ext4, xfs).
51    pub fstype: String,
52    /// Mount options.
53    pub options: String,
54    /// The filesystem UUID, if available.
55    pub uuid: Option<String>,
56    /// Child filesystems, if any.
57    pub children: Option<Vec<Filesystem>>,
58}
59
60/// Deserialized output of `findmnt --json`.
61#[derive(Deserialize, Debug, Default)]
62pub struct Findmnt {
63    /// The list of mounted filesystems.
64    pub filesystems: Vec<Filesystem>,
65}
66
67/// Run `findmnt` with JSON output and parse the result.
68pub fn run_findmnt(args: &[&str], cwd: Option<&Dir>, path: Option<&str>) -> Result<Findmnt> {
69    let mut cmd = Command::new("findmnt");
70    if let Some(cwd) = cwd {
71        cmd.cwd_dir(cwd.try_clone()?);
72    }
73    cmd.args([
74        "-J",
75        "-v",
76        // If you change this you probably also want to change the Filesystem struct above
77        "--output=SOURCE,TARGET,MAJ:MIN,FSTYPE,OPTIONS,UUID",
78    ])
79    .args(args)
80    .args(path);
81    let o: Findmnt = cmd.log_debug().run_and_parse_json()?;
82    Ok(o)
83}
84
85// Retrieve a mounted filesystem from a device given a matching path
86fn findmnt_filesystem(args: &[&str], cwd: Option<&Dir>, path: &str) -> Result<Filesystem> {
87    let o = run_findmnt(args, cwd, Some(path))?;
88    o.filesystems
89        .into_iter()
90        .next()
91        .ok_or_else(|| anyhow!("findmnt returned no data for {path}"))
92}
93
94#[context("Inspecting filesystem {path}")]
95/// Inspect a target which must be a mountpoint root - it is an error
96/// if the target is not the mount root.
97pub fn inspect_filesystem(path: &Utf8Path) -> Result<Filesystem> {
98    findmnt_filesystem(&["--mountpoint"], None, path.as_str())
99}
100
101#[context("Inspecting filesystem")]
102/// Inspect a target which must be a mountpoint root - it is an error
103/// if the target is not the mount root.
104pub fn inspect_filesystem_of_dir(d: &Dir) -> Result<Filesystem> {
105    findmnt_filesystem(&["--mountpoint"], Some(d), ".")
106}
107
108#[context("Inspecting filesystem by UUID {uuid}")]
109/// Inspect a filesystem by partition UUID
110pub fn inspect_filesystem_by_uuid(uuid: &str) -> Result<Filesystem> {
111    findmnt_filesystem(&["--source"], None, &(format!("UUID={uuid}")))
112}
113
114/// Return the list of mounts visible in pid 1's mount namespace, as reported by findmnt.
115fn pid1_mounts() -> Result<Findmnt> {
116    run_findmnt(&["-N"], None, Some("1"))
117}
118
119/// Check if a specified device contains an already mounted filesystem
120/// in the root mount namespace.
121pub fn is_mounted_in_pid1_mountns(path: &str) -> Result<bool> {
122    let o = pid1_mounts()?;
123
124    let mounted = o.filesystems.iter().any(|fs| is_source_mounted(path, fs));
125
126    Ok(mounted)
127}
128
129/// Find the mount target of a given source device in the *current* mount
130/// namespace. Returns `Ok(None)` if the device is not mounted.
131///
132/// Used by callers that want to gracefully handle the case where a
133/// device they intended to mount is already mounted somewhere else
134/// (which would cause a fresh `mount(2)` to return `EBUSY`). Note this
135/// intentionally queries the caller's own mount namespace, not pid 1's
136/// (unlike `is_mounted_in_pid1_mountns`, which exists to answer a
137/// different question: whether the *host* already has something
138/// mounted before bootc unshares its own namespace). Callers of this
139/// function are expected to have already unshared their own mount
140/// namespace and are looking up a mount they intend to operate on
141/// directly (e.g. remount) in that same namespace.
142pub fn find_mount_target_by_source(dev: &str) -> Result<Option<camino::Utf8PathBuf>> {
143    let o = run_findmnt(&[], None, None)?;
144    let found = find_source_mount_target(dev, &o.filesystems);
145    if found.is_none() {
146        tracing::debug!(
147            "find_mount_target_by_source: no mount found for source {dev}; \
148             note that findmnt may report by-uuid or by-partuuid paths instead \
149             of the block device path in some environments"
150        );
151    }
152    Ok(found)
153}
154
155fn find_source_mount_target(dev: &str, mounts: &[Filesystem]) -> Option<camino::Utf8PathBuf> {
156    for m in mounts {
157        if m.source == dev {
158            return Some(camino::Utf8PathBuf::from(&m.target));
159        }
160        if let Some(children) = &m.children {
161            if let Some(t) = find_source_mount_target(dev, children) {
162                return Some(t);
163            }
164        }
165    }
166    None
167}
168
169/// Recursively check a given filesystem to see if it contains an already mounted source.
170pub fn is_source_mounted(path: &str, mounted_fs: &Filesystem) -> bool {
171    if mounted_fs.source.contains(path) {
172        return true;
173    }
174
175    if let Some(ref children) = mounted_fs.children {
176        for child in children {
177            if is_source_mounted(path, child) {
178                return true;
179            }
180        }
181    }
182
183    false
184}
185
186/// Mount a device to the target path.
187pub fn mount(dev: &str, target: &Utf8Path) -> Result<()> {
188    Command::new("mount")
189        .args([dev, target.as_str()])
190        .run_inherited_with_cmd_context()
191}
192
193/// Mount a device with an explicit filesystem type.
194///
195/// This avoids relying on the `mount` utility's blkid auto-detection,
196/// which can fail in certain container environments (e.g. when the
197/// required filesystem kernel module is not yet loaded and the blkid
198/// probe doesn't work, causing mount to fall back to iterating
199/// `/etc/filesystems` and `/proc/filesystems`).
200pub fn mount_typed(dev: &str, fstype: &str, target: &Utf8Path) -> Result<()> {
201    Command::new("mount")
202        .args(["-t", fstype, dev, target.as_str()])
203        .run_inherited_with_cmd_context()
204}
205
206/// Recursively unmount a tree assembled by a caller-owned operation.
207pub fn unmount_recursive(target: &Utf8Path) -> Result<()> {
208    Command::new("umount")
209        .args(["--recursive", target.as_str()])
210        .run_inherited_with_cmd_context()
211}
212
213/// If the fsid of the passed path matches the fsid of the same path rooted
214/// at /proc/1/root, it is assumed that these are indeed the same mounted
215/// filesystem between container and host.
216/// Path should be absolute.
217#[context("Comparing filesystems at {path} and /proc/1/root/{path}")]
218pub fn is_same_as_host(path: &Utf8Path) -> Result<bool> {
219    // Add a leading '/' in case a relative path is passed
220    let path = Utf8Path::new("/").join(path);
221
222    // Using statvfs instead of fs, since rustix will translate the fsid field
223    // for us.
224    let devstat = rustix::fs::statvfs(path.as_std_path())?;
225    let hostpath = Utf8Path::new("/proc/1/root").join(path.strip_prefix("/")?);
226    let hostdevstat = rustix::fs::statvfs(hostpath.as_std_path())?;
227    tracing::trace!(
228        "base mount id {:?}, host mount id {:?}",
229        devstat.f_fsid,
230        hostdevstat.f_fsid
231    );
232    Ok(devstat.f_fsid == hostdevstat.f_fsid)
233}
234
235/// Given a pid, enter its mount namespace and acquire a file descriptor
236/// for a mount from that namespace.
237#[allow(unsafe_code)]
238#[context("Opening mount tree from pid")]
239pub fn open_tree_from_pidns(
240    pid: rustix::process::Pid,
241    path: &Utf8Path,
242    recursive: bool,
243) -> Result<OwnedFd> {
244    // Allocate a socket pair to use for sending file descriptors.
245    let (sock_parent, sock_child) = rustix::net::socketpair(
246        AddressFamily::UNIX,
247        SocketType::STREAM,
248        SocketFlags::CLOEXEC,
249        None,
250    )
251    .context("socketpair")?;
252    const DUMMY_DATA: &[u8] = b"!";
253    match unsafe { libc::fork() } {
254        0 => {
255            // We're in the child. At this point we know we don't have multiple threads, so we
256            // can safely `setns`.
257
258            drop(sock_parent);
259
260            // Open up the namespace of the target process as a file descriptor, and enter it.
261            let pidlink = fs::File::open(format!("/proc/{}/ns/mnt", pid.as_raw_nonzero()))?;
262            rustix::thread::move_into_link_name_space(
263                pidlink.as_fd(),
264                Some(rustix::thread::LinkNameSpaceType::Mount),
265            )
266            .context("setns")?;
267
268            // Open the target mount path as a file descriptor.
269            let recursive = if recursive {
270                OpenTreeFlags::AT_RECURSIVE
271            } else {
272                OpenTreeFlags::empty()
273            };
274            let fd = rustix::mount::open_tree(
275                rustix::fs::CWD,
276                path.as_std_path(),
277                OpenTreeFlags::OPEN_TREE_CLOEXEC | OpenTreeFlags::OPEN_TREE_CLONE | recursive,
278            )
279            .context("open_tree")?;
280
281            // And send that file descriptor via fd passing over the socketpair.
282            let fd = fd.as_fd();
283            let fds = [fd];
284            let mut buffer = [MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))];
285            let mut control = SendAncillaryBuffer::new(&mut buffer);
286            let pushed = control.push(SendAncillaryMessage::ScmRights(&fds));
287            assert!(pushed);
288            let ios = std::io::IoSlice::new(DUMMY_DATA);
289            rustix::net::sendmsg(sock_child, &[ios], &mut control, SendFlags::empty())?;
290            // Then we're done.
291            std::process::exit(0)
292        }
293        -1 => {
294            // fork failed
295            let e = std::io::Error::last_os_error();
296            anyhow::bail!("failed to fork: {e}");
297        }
298        n => {
299            // We're in the parent; create a pid (checking that n > 0).
300            let pid = rustix::process::Pid::from_raw(n).unwrap();
301            drop(sock_child);
302            // Receive the mount file descriptor from the child
303            let mut cmsg_space = vec![MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))];
304            let mut cmsg_buffer = rustix::net::RecvAncillaryBuffer::new(&mut cmsg_space);
305            let mut buf = [0u8; DUMMY_DATA.len()];
306            let iov = std::io::IoSliceMut::new(buf.as_mut());
307            let mut iov = [iov];
308            let nread = rustix::net::recvmsg(
309                sock_parent,
310                &mut iov,
311                &mut cmsg_buffer,
312                RecvFlags::CMSG_CLOEXEC,
313            )
314            .context("recvmsg")?
315            .bytes;
316            anyhow::ensure!(nread == DUMMY_DATA.len());
317            assert_eq!(buf, DUMMY_DATA);
318            // And extract the file descriptor
319            let r = cmsg_buffer
320                .drain()
321                .filter_map(|m| match m {
322                    rustix::net::RecvAncillaryMessage::ScmRights(f) => Some(f),
323                    _ => None,
324                })
325                .flatten()
326                .next()
327                .ok_or_else(|| anyhow::anyhow!("Did not receive a file descriptor"))?;
328            // SAFETY: Since we're not setting WNOHANG, this will always return Some().
329            let st = rustix::process::waitpid(Some(pid), WaitOptions::empty())?
330                .expect("Wait status")
331                .1;
332            if let Some(0) = st.exit_status() {
333                Ok(r)
334            } else {
335                anyhow::bail!("forked helper failed: {st:?}");
336            }
337        }
338    }
339}
340
341/// Create a bind mount from the mount namespace of the target pid
342/// into our mount namespace.
343pub fn bind_mount_from_pidns(
344    pid: Pid,
345    src: &Utf8Path,
346    target: &Utf8Path,
347    recursive: bool,
348) -> Result<()> {
349    let src = open_tree_from_pidns(pid, src, recursive)?;
350    rustix::mount::move_mount(
351        src.as_fd(),
352        "",
353        rustix::fs::CWD,
354        target.as_std_path(),
355        MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH,
356    )
357    .context("Moving mount")?;
358    Ok(())
359}
360
361/// If the target path is not already mirrored from the host (e.g. via `-v /dev:/dev`)
362/// then recursively mount it.
363pub fn ensure_mirrored_host_mount(path: impl AsRef<Utf8Path>) -> Result<()> {
364    let path = path.as_ref();
365    // If we didn't have this in our filesystem already (e.g. for /var/lib/containers)
366    // then create it now.
367    std::fs::create_dir_all(path)?;
368    if is_same_as_host(path)? {
369        tracing::debug!("Already mounted from host: {path}");
370        return Ok(());
371    }
372    tracing::debug!("Propagating host mount: {path}");
373    bind_mount_from_pidns(PID1, path, path, true)
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use camino::Utf8PathBuf;
380
381    fn mk_fs(source: &str, target: &str, children: Vec<Filesystem>) -> Filesystem {
382        Filesystem {
383            source: source.into(),
384            target: target.into(),
385            maj_min: "0:0".into(),
386            fstype: "ext4".into(),
387            options: "rw".into(),
388            uuid: None,
389            children: if children.is_empty() {
390                None
391            } else {
392                Some(children)
393            },
394        }
395    }
396
397    #[test]
398    fn find_source_mount_target_cases() {
399        let top_level = vec![
400            mk_fs("/dev/vda2", "/sysroot", vec![]),
401            mk_fs("/dev/vda1", "/boot", vec![]),
402        ];
403        // /boot as a child mount nested under /sysroot — the shape findmnt -J
404        // returns when /boot is under sysroot.
405        let nested = vec![mk_fs(
406            "/dev/vda2",
407            "/sysroot",
408            vec![mk_fs("/dev/vda1", "/sysroot/boot", vec![])],
409        )];
410        // A real composefs + LUKS-/var shape: /dev/vda2 appears twice (as
411        // sysroot and as its /var overlay parent), and /dev/mapper/var is
412        // nested a further level down. Guards against a naive walk that
413        // would stop at the first /dev/vda2 match instead of recursing.
414        let deep = vec![mk_fs(
415            "/dev/vda2",
416            "/sysroot",
417            vec![mk_fs(
418                "/dev/vda2",
419                "/var",
420                vec![mk_fs("/dev/mapper/var", "/var", vec![])],
421            )],
422        )];
423
424        let cases: &[(&str, &str, &[Filesystem], Option<&str>)] = &[
425            ("top-level match", "/dev/vda1", &top_level, Some("/boot")),
426            ("nested match", "/dev/vda1", &nested, Some("/sysroot/boot")),
427            ("not found", "/dev/vda1", &top_level[..1], None),
428            ("empty tree", "/dev/vda1", &[], None),
429            ("deep nesting", "/dev/mapper/var", &deep, Some("/var")),
430        ];
431
432        for (name, dev, tree, expected) in cases {
433            let got = find_source_mount_target(dev, tree);
434            let want = expected.map(Utf8PathBuf::from);
435            assert_eq!(got, want, "case: {name}");
436        }
437    }
438}