Skip to main content

bootc_internal_utils/
chroot.rs

1//! Builder for running commands inside a target os tree using a
2//! mount namespace + chroot. Requires `CAP_SYS_ADMIN`.
3
4use std::borrow::Cow;
5use std::ffi::{CString, OsStr};
6use std::fs::create_dir_all;
7use std::os::unix::process::CommandExt;
8use std::process::Command;
9
10use anyhow::{Context, Result};
11use cap_std_ext::camino::Utf8Path;
12use rustix::mount::{
13    MountFlags, MountPropagationFlags, mount, mount_bind, mount_bind_recursive, mount_change,
14};
15use rustix::process::{chdir, chroot};
16use rustix::thread::{UnshareFlags, unshare_unsafe};
17
18use crate::CommandRunExt;
19
20/// Whether a [`ChrootCmd::bind`] mount also carries over mounts nested
21/// under its source.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum BindMode {
24    /// A plain bind mount: only `source` itself is bound. Mounts nested
25    /// under `source` on the host (e.g. an ESP mounted under a `/boot`
26    /// source) are *not* carried over, leaving the corresponding
27    /// mountpoint directories under `target` empty.
28    Default,
29    /// A recursive bind mount: mounts nested under `source` on the host
30    /// are also visible under `target` inside the chroot.
31    Recursive,
32}
33
34/// Builder for running commands inside a target directory using a
35/// mount namespace + chroot.
36#[derive(Debug)]
37pub struct ChrootCmd<'a> {
38    /// The target directory to use as root for the chroot.
39    chroot_path: Cow<'a, Utf8Path>,
40    /// Bind mounts in format (host source, chroot-relative target, mode).
41    bind_mounts: Vec<(&'a str, &'a str, BindMode)>,
42    /// Environment variables to set on the spawned command.
43    env_vars: Vec<(&'a str, &'a str)>,
44}
45
46impl<'a> ChrootCmd<'a> {
47    /// Create a new `ChrootCmd` builder with a root directory.
48    pub fn new(path: &'a Utf8Path) -> Self {
49        Self {
50            chroot_path: Cow::Borrowed(path),
51            bind_mounts: Vec::new(),
52            env_vars: Vec::new(),
53        }
54    }
55
56    /// Add a bind mount from `source` (on the host) to `target` (a path
57    /// inside the chroot, e.g. `/sysroot`). See [`BindMode`] for the
58    /// difference between recursive and non-recursive bind mounts.
59    pub fn bind(
60        mut self,
61        source: &'a impl AsRef<Utf8Path>,
62        target: &'a impl AsRef<Utf8Path>,
63        mode: BindMode,
64    ) -> Self {
65        self.bind_mounts
66            .push((source.as_ref().as_str(), target.as_ref().as_str(), mode));
67        self
68    }
69
70    /// Set an environment variable for the child. The chrooted
71    /// command runs with a cleared environment, isolating it from
72    /// the buildroot — callers must set every variable they want
73    /// the child to see.
74    pub fn setenv(mut self, key: &'a str, value: &'a str) -> Self {
75        self.env_vars.push((key, value));
76        self
77    }
78
79    /// Set `$PATH` to a reasonable default covering the standard
80    /// system binary directories.
81    pub fn set_default_path(self) -> Self {
82        self.setenv(
83            "PATH",
84            "/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin",
85        )
86    }
87
88    /// Build the underlying [`Command`] with the mount-namespace
89    /// setup and chroot installed as a `pre_exec` hook.
90    fn build_command<S: AsRef<OsStr>>(self, args: impl IntoIterator<Item = S>) -> Result<Command> {
91        let mut args_iter = args.into_iter();
92        let program = args_iter
93            .next()
94            .context("ChrootCmd requires the program as the first arg")?;
95
96        // mount() requires its target directories to exist.
97        let proc_target = self.chroot_path.join("proc");
98        let dev_target = self.chroot_path.join("dev");
99        let sys_target = self.chroot_path.join("sys");
100        let run_target = self.chroot_path.join("run");
101        for p in [&proc_target, &dev_target, &sys_target, &run_target] {
102            create_dir_all(p).with_context(|| format!("Creating {p}"))?;
103        }
104
105        // Convert paths to CStrings up front so the pre_exec closure
106        // below stays allocation-free.
107        let proc_target = CString::new(proc_target.as_str())?;
108        let dev_target = CString::new(dev_target.as_str())?;
109        let sys_target = CString::new(sys_target.as_str())?;
110        let run_target = CString::new(run_target.as_str())?;
111
112        let user_binds: Vec<(CString, CString, BindMode)> = self
113            .bind_mounts
114            .iter()
115            .map(|(src, tgt, mode)| -> Result<_> {
116                let tgt_in_chroot = self.chroot_path.join(tgt.trim_start_matches('/'));
117                create_dir_all(&tgt_in_chroot)
118                    .with_context(|| format!("Creating bind target {tgt_in_chroot}"))?;
119                Ok((
120                    CString::new(*src)?,
121                    CString::new(tgt_in_chroot.as_str())?,
122                    *mode,
123                ))
124            })
125            .collect::<Result<_>>()?;
126
127        let chroot_cstr = CString::new(self.chroot_path.as_str())?;
128
129        let mut cmd = Command::new(program);
130        cmd.args(args_iter);
131        cmd.env_clear().envs(self.env_vars.iter().copied());
132
133        // SAFETY: All operations below are safe to invoke between
134        // fork and exec — only rustix-wrapped syscalls and iteration
135        // over CStrings allocated above.
136        #[allow(unsafe_code)]
137        unsafe {
138            cmd.pre_exec(move || {
139                unshare_unsafe(UnshareFlags::NEWNS)?;
140
141                // Recursively mark every mount in our new namespace as
142                // PRIVATE. This both prevents the mounts we add below
143                // from leaking back to the host, and ensures that those
144                // mounts inherit PRIVATE propagation from their parent.
145                mount_change(
146                    c"/",
147                    MountPropagationFlags::PRIVATE | MountPropagationFlags::REC,
148                )?;
149
150                // Bind-mount the chroot target onto itself so that `/`
151                // appears as a real mount point after chroot. Without
152                // this, tools that inspect mounts (e.g. `findmnt
153                // --mountpoint /`, which bootupd uses behind
154                // `--filesystem /`) fail because the chroot dir is a
155                // plain subdirectory of its parent mount and has no
156                // mountinfo entry of its own.
157                mount_bind_recursive(chroot_cstr.as_c_str(), chroot_cstr.as_c_str())?;
158
159                // Setup API filesystems
160                // See https://systemd.io/API_FILE_SYSTEMS/
161                mount(
162                    c"proc",
163                    proc_target.as_c_str(),
164                    c"proc",
165                    MountFlags::empty(),
166                    None,
167                )?;
168                mount_bind_recursive(c"/dev", dev_target.as_c_str())?;
169                mount_bind_recursive(c"/sys", sys_target.as_c_str())?;
170                // /run carries the udev database, which lsblk/libblkid
171                // use to resolve partition GUIDs and other device
172                // properties.
173                mount_bind_recursive(c"/run", run_target.as_c_str())?;
174
175                for (src, tgt, mode) in &user_binds {
176                    match mode {
177                        BindMode::Recursive => {
178                            mount_bind_recursive(src.as_c_str(), tgt.as_c_str())?
179                        }
180                        BindMode::Default => mount_bind(src.as_c_str(), tgt.as_c_str())?,
181                    }
182                }
183
184                chroot(chroot_cstr.as_c_str())?;
185                chdir(c"/")?;
186
187                Ok(())
188            });
189        }
190
191        Ok(cmd)
192    }
193
194    /// Run the specified command inside the chroot, inheriting stdio.
195    /// `args` must include the program as its first element.
196    pub fn run<S: AsRef<OsStr>>(self, args: impl IntoIterator<Item = S>) -> Result<()> {
197        self.build_command(args)?
198            .log_debug()
199            .run_inherited_with_cmd_context()
200    }
201
202    /// Run the specified command inside the chroot and capture stdout
203    /// as a string. `args` must include the program as its first
204    /// element.
205    pub fn run_get_string<S: AsRef<OsStr>>(
206        self,
207        args: impl IntoIterator<Item = S>,
208    ) -> Result<String> {
209        self.build_command(args)?.log_debug().run_get_string()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use cap_std_ext::camino::Utf8PathBuf;
217
218    fn tmp_root() -> (tempfile::TempDir, Utf8PathBuf) {
219        let dir = tempfile::tempdir().unwrap();
220        let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap();
221        (dir, path)
222    }
223
224    #[test]
225    fn builder_accumulates_binds_and_env() {
226        let (_keep, root) = tmp_root();
227        let src = root.join("src");
228        let non_recursive_src = root.join("non-recursive-src");
229        let cmd = ChrootCmd::new(&root)
230            .bind(&src, &"/boot", BindMode::Recursive)
231            .bind(&non_recursive_src, &"/sysroot", BindMode::Default)
232            .setenv("FOO", "bar")
233            .set_default_path();
234        assert_eq!(cmd.bind_mounts.len(), 2);
235        assert_eq!(
236            cmd.bind_mounts[0],
237            (src.as_str(), "/boot", BindMode::Recursive)
238        );
239        assert_eq!(
240            cmd.bind_mounts[1],
241            (non_recursive_src.as_str(), "/sysroot", BindMode::Default)
242        );
243        // setenv + set_default_path
244        assert_eq!(cmd.env_vars.len(), 2);
245        assert!(cmd.env_vars.iter().any(|(k, _)| *k == "PATH"));
246        assert!(cmd.env_vars.iter().any(|(k, v)| *k == "FOO" && *v == "bar"));
247    }
248
249    #[test]
250    fn build_command_creates_api_mount_dirs() {
251        let (_keep, root) = tmp_root();
252        // No user binds — just the API mount targets.
253        let cmd = ChrootCmd::new(&root).build_command(["/bin/true"]).unwrap();
254        for sub in ["proc", "dev", "sys", "run"] {
255            assert!(
256                root.join(sub).is_dir(),
257                "API mount dir {sub} not created in {root}"
258            );
259        }
260        assert_eq!(cmd.get_program(), "/bin/true");
261    }
262
263    #[test]
264    fn build_command_creates_user_bind_targets() {
265        let (_keep, root) = tmp_root();
266        let (_keep2, src_root) = tmp_root();
267        ChrootCmd::new(&root)
268            .bind(&src_root, &"/sysroot", BindMode::Recursive)
269            .build_command(["/bin/true"])
270            .unwrap();
271        assert!(root.join("sysroot").is_dir());
272    }
273
274    #[test]
275    fn build_command_rejects_empty_args() {
276        let (_keep, root) = tmp_root();
277        let err = ChrootCmd::new(&root)
278            .build_command(std::iter::empty::<&str>())
279            .unwrap_err();
280        assert!(
281            err.to_string().contains("ChrootCmd requires the program"),
282            "unexpected error: {err}"
283        );
284    }
285}