Skip to main content

bob/sandbox/
sandbox_linux.rs

1/*
2 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
3 *
4 * Permission to use, copy, modify, and distribute this software for any
5 * purpose with or without fee is hereby granted, provided that the above
6 * copyright notice and this permission notice appear in all copies.
7 *
8 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15 */
16
17use crate::sandbox::Sandbox;
18use anyhow::Context;
19use std::fs;
20use std::os::unix::process::CommandExt;
21use std::path::Path;
22use std::process::{Command, ExitStatus, Stdio};
23use tracing::{debug, info, warn};
24
25impl Sandbox {
26    pub fn mount_bindfs(
27        &self,
28        src: &Path,
29        dest: &Path,
30        opts: &[&str],
31    ) -> anyhow::Result<Option<ExitStatus>> {
32        fs::create_dir_all(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
33        let cmd = "/bin/mount";
34        // Build mount options: start with "bind", add any user-specified opts
35        let mut mount_opts = vec!["bind"];
36        mount_opts.extend(opts.iter().copied());
37        let opts_str = mount_opts.join(",");
38        Ok(Some(
39            Command::new(cmd)
40                .arg("-o")
41                .arg(&opts_str)
42                .arg(src)
43                .arg(dest)
44                .process_group(0)
45                .status()
46                .context(format!("Unable to execute {}", cmd))?,
47        ))
48    }
49
50    pub fn mount_devfs(
51        &self,
52        _src: &Path,
53        dest: &Path,
54        opts: &[&str],
55    ) -> anyhow::Result<Option<ExitStatus>> {
56        fs::create_dir_all(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
57        let cmd = "/bin/mount";
58        Ok(Some(
59            Command::new(cmd)
60                .arg("-t")
61                .arg("devtmpfs")
62                .args(opts)
63                .arg("devtmpfs")
64                .arg(dest)
65                .process_group(0)
66                .status()
67                .context(format!("Unable to execute {}", cmd))?,
68        ))
69    }
70
71    pub fn mount_fdfs(
72        &self,
73        _src: &Path,
74        dest: &Path,
75        opts: &[&str],
76    ) -> anyhow::Result<Option<ExitStatus>> {
77        fs::create_dir_all(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
78        let cmd = "/bin/mount";
79        // Build mount options: start with "bind", add any user-specified opts
80        let mut mount_opts = vec!["bind"];
81        mount_opts.extend(opts.iter().copied());
82        let opts_str = mount_opts.join(",");
83        Ok(Some(
84            Command::new(cmd)
85                .arg("-o")
86                .arg(&opts_str)
87                .arg("/dev/fd")
88                .arg(dest)
89                .process_group(0)
90                .status()
91                .context(format!("Unable to execute {}", cmd))?,
92        ))
93    }
94
95    pub fn mount_nfs(
96        &self,
97        src: &Path,
98        dest: &Path,
99        opts: &[&str],
100    ) -> anyhow::Result<Option<ExitStatus>> {
101        fs::create_dir_all(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
102        let cmd = "/bin/mount";
103        Ok(Some(
104            Command::new(cmd)
105                .arg("-t")
106                .arg("nfs")
107                .args(opts)
108                .arg(src)
109                .arg(dest)
110                .process_group(0)
111                .status()
112                .context(format!("Unable to execute {}", cmd))?,
113        ))
114    }
115
116    pub fn mount_procfs(
117        &self,
118        _src: &Path,
119        dest: &Path,
120        opts: &[&str],
121    ) -> anyhow::Result<Option<ExitStatus>> {
122        fs::create_dir_all(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
123        let cmd = "/bin/mount";
124        Ok(Some(
125            Command::new(cmd)
126                .arg("-t")
127                .arg("proc")
128                .args(opts)
129                .arg("proc")
130                .arg(dest)
131                .process_group(0)
132                .status()
133                .context(format!("Unable to execute {}", cmd))?,
134        ))
135    }
136
137    pub fn mount_tmpfs(
138        &self,
139        _src: &Path,
140        dest: &Path,
141        opts: &[&str],
142    ) -> anyhow::Result<Option<ExitStatus>> {
143        fs::create_dir_all(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
144        let cmd = "/bin/mount";
145        let mut args = vec!["-t", "tmpfs"];
146        // Convert opts to mount -o style if they look like size options
147        let mut mount_opts: Vec<String> = vec![];
148        for opt in opts {
149            if opt.starts_with("size=") || opt.starts_with("mode=") {
150                mount_opts.push(opt.to_string());
151            }
152        }
153        if !mount_opts.is_empty() {
154            args.push("-o");
155        }
156        let opts_str = mount_opts.join(",");
157        Ok(Some(
158            Command::new(cmd)
159                .args(&args)
160                .arg(if !mount_opts.is_empty() {
161                    &opts_str
162                } else {
163                    ""
164                })
165                .arg("tmpfs")
166                .arg(dest)
167                .process_group(0)
168                .status()
169                .context(format!("Unable to execute {}", cmd))?,
170        ))
171    }
172
173    fn unmount_common(&self, dest: &Path) -> anyhow::Result<Option<ExitStatus>> {
174        let cmd = "/bin/umount";
175        // Use process_group(0) to put umount in its own process group.
176        // This prevents it from receiving SIGINT when the user presses Ctrl+C,
177        // ensuring cleanup can complete even during repeated interrupts.
178        Ok(Some(
179            Command::new(cmd)
180                .arg(dest)
181                .stdout(Stdio::null())
182                .stderr(Stdio::null())
183                .process_group(0)
184                .status()
185                .context(format!("Unable to execute {}", cmd))?,
186        ))
187    }
188
189    pub fn unmount_bindfs(&self, dest: &Path) -> anyhow::Result<Option<ExitStatus>> {
190        self.unmount_common(dest)
191    }
192
193    pub fn unmount_devfs(&self, dest: &Path) -> anyhow::Result<Option<ExitStatus>> {
194        self.unmount_common(dest)
195    }
196
197    pub fn unmount_fdfs(&self, dest: &Path) -> anyhow::Result<Option<ExitStatus>> {
198        self.unmount_common(dest)
199    }
200
201    pub fn unmount_nfs(&self, dest: &Path) -> anyhow::Result<Option<ExitStatus>> {
202        self.unmount_common(dest)
203    }
204
205    pub fn unmount_procfs(&self, dest: &Path) -> anyhow::Result<Option<ExitStatus>> {
206        self.unmount_common(dest)
207    }
208
209    pub fn unmount_tmpfs(&self, dest: &Path) -> anyhow::Result<Option<ExitStatus>> {
210        self.unmount_common(dest)
211    }
212
213    /**
214     * Kill processes using a specific mount point.
215     *
216     * Uses fuser -km (Linux mount point mode + kill) to identify and kill
217     * processes using the mount point.
218     */
219    pub fn kill_processes_for_path(&self, path: &Path) {
220        for iteration in 0..super::KILL_PROCESSES_MAX_RETRIES {
221            let output = Command::new("fuser")
222                .arg("-m")
223                .arg(path)
224                .stdout(Stdio::piped())
225                .stderr(Stdio::null())
226                .process_group(0)
227                .output();
228
229            let Ok(out) = output else { return };
230
231            let stdout = String::from_utf8_lossy(&out.stdout);
232            if stdout.split_whitespace().next().is_none() {
233                return;
234            }
235
236            debug!(path = %path.display(), "Killing processes for mount");
237
238            let _ = Command::new("fuser")
239                .arg("-km")
240                .arg(path)
241                .stdout(Stdio::null())
242                .stderr(Stdio::null())
243                .process_group(0)
244                .status();
245
246            let delay_ms = super::KILL_PROCESSES_INITIAL_DELAY_MS << iteration;
247            std::thread::sleep(std::time::Duration::from_millis(delay_ms));
248        }
249    }
250
251    /// Kill all processes with open file handles within a sandbox path.
252    ///
253    /// Uses procfs to scan all processes for file descriptors, cwd, or root
254    /// that point into the sandbox directory. This is more thorough than
255    /// `fuser` which only checks the exact path, not files within subdirs.
256    pub fn kill_processes(&self, sandbox: &Path) {
257        for iteration in 0..super::KILL_PROCESSES_MAX_RETRIES {
258            let mut killed: Vec<i32> = Vec::new();
259
260            // Scan all processes
261            if let Ok(procs) = procfs::process::all_processes() {
262                for proc in procs.flatten() {
263                    if Self::process_uses_path(&proc, sandbox).is_some() {
264                        killed.push(proc.pid);
265                        unsafe {
266                            libc::kill(proc.pid, libc::SIGKILL);
267                        }
268                    }
269                }
270            }
271
272            if killed.is_empty() {
273                debug!(retries = iteration, "No processes found in sandbox");
274                return;
275            }
276
277            let pids: Vec<String> = killed.iter().map(|p| p.to_string()).collect();
278            info!(pids = %pids.join(" "), "Killed processes using sandbox");
279
280            // Give processes a moment to die (exponential backoff)
281            let delay_ms = super::KILL_PROCESSES_INITIAL_DELAY_MS << iteration;
282            std::thread::sleep(std::time::Duration::from_millis(delay_ms));
283        }
284        // Get info about remaining processes for the warning
285        let proc_info = Self::get_process_info(sandbox);
286        warn!(
287            max_retries = super::KILL_PROCESSES_MAX_RETRIES,
288            remaining = %proc_info,
289            "Gave up killing processes after max retries"
290        );
291    }
292
293    /// Get info about processes using files in a directory.
294    fn get_process_info(sandbox: &Path) -> String {
295        let mut info = Vec::new();
296        if let Ok(procs) = procfs::process::all_processes() {
297            for proc in procs.flatten() {
298                if Self::process_uses_path(&proc, sandbox).is_some() {
299                    let cmdline = proc
300                        .cmdline()
301                        .map(|c| c.join(" "))
302                        .unwrap_or_else(|_| String::from("?"));
303                    info.push(format!("pid={} cmd='{}'", proc.pid, cmdline));
304                }
305            }
306        }
307        if info.is_empty() {
308            String::from("(none)")
309        } else {
310            info.join(", ")
311        }
312    }
313
314    /// Check if a process has any references to paths under the given directory.
315    /// Returns Some(reason) describing why the process matches, or None.
316    fn process_uses_path(proc: &procfs::process::Process, dir: &Path) -> Option<String> {
317        // Check cwd
318        if let Ok(cwd) = proc.cwd() {
319            if cwd.starts_with(dir) {
320                return Some(format!("cwd={}", cwd.display()));
321            }
322        }
323
324        // Check root (chroot)
325        if let Ok(root) = proc.root() {
326            if root.starts_with(dir) {
327                return Some(format!("root={}", root.display()));
328            }
329        }
330
331        // Check all open file descriptors
332        if let Ok(fds) = proc.fd() {
333            for fd in fds.flatten() {
334                if let procfs::process::FDTarget::Path(path) = fd.target {
335                    if path.starts_with(dir) {
336                        return Some(format!("fd={}", path.display()));
337                    }
338                }
339            }
340        }
341
342        None
343    }
344}