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