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)?;
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)?;
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)?;
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)?;
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)?;
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)?;
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() { &opts_str } else { "" })
161                .arg("tmpfs")
162                .arg(dest)
163                .process_group(0)
164                .status()
165                .context(format!("Unable to execute {}", cmd))?,
166        ))
167    }
168
169    fn unmount_common(
170        &self,
171        dest: &Path,
172    ) -> anyhow::Result<Option<ExitStatus>> {
173        let cmd = "/bin/umount";
174        // Use process_group(0) to put umount in its own process group.
175        // This prevents it from receiving SIGINT when the user presses Ctrl+C,
176        // ensuring cleanup can complete even during repeated interrupts.
177        Ok(Some(
178            Command::new(cmd)
179                .arg(dest)
180                .stdout(Stdio::null())
181                .stderr(Stdio::null())
182                .process_group(0)
183                .status()
184                .context(format!("Unable to execute {}", cmd))?,
185        ))
186    }
187
188    pub fn unmount_bindfs(
189        &self,
190        dest: &Path,
191    ) -> anyhow::Result<Option<ExitStatus>> {
192        self.unmount_common(dest)
193    }
194
195    pub fn unmount_devfs(
196        &self,
197        dest: &Path,
198    ) -> anyhow::Result<Option<ExitStatus>> {
199        self.unmount_common(dest)
200    }
201
202    pub fn unmount_fdfs(
203        &self,
204        dest: &Path,
205    ) -> anyhow::Result<Option<ExitStatus>> {
206        self.unmount_common(dest)
207    }
208
209    pub fn unmount_nfs(
210        &self,
211        dest: &Path,
212    ) -> anyhow::Result<Option<ExitStatus>> {
213        self.unmount_common(dest)
214    }
215
216    pub fn unmount_procfs(
217        &self,
218        dest: &Path,
219    ) -> anyhow::Result<Option<ExitStatus>> {
220        self.unmount_common(dest)
221    }
222
223    pub fn unmount_tmpfs(
224        &self,
225        dest: &Path,
226    ) -> anyhow::Result<Option<ExitStatus>> {
227        self.unmount_common(dest)
228    }
229
230    /// Kill all processes with open file handles within a sandbox path.
231    ///
232    /// Uses procfs to scan all processes for file descriptors, cwd, or root
233    /// that point into the sandbox directory. This is more thorough than
234    /// `fuser` which only checks the exact path, not files within subdirs.
235    pub fn kill_processes(&self, sandbox: &Path) {
236        for iteration in 0..super::KILL_PROCESSES_MAX_RETRIES {
237            let mut killed: Vec<i32> = Vec::new();
238
239            // Scan all processes
240            if let Ok(procs) = procfs::process::all_processes() {
241                for proc in procs.flatten() {
242                    if Self::process_uses_path(&proc, sandbox).is_some() {
243                        killed.push(proc.pid);
244                        unsafe {
245                            libc::kill(proc.pid, libc::SIGKILL);
246                        }
247                    }
248                }
249            }
250
251            if killed.is_empty() {
252                debug!(retries = iteration, "No processes found in sandbox");
253                return;
254            }
255
256            let pids: Vec<String> =
257                killed.iter().map(|p| p.to_string()).collect();
258            info!(pids = %pids.join(" "), "Killed processes using sandbox");
259
260            // Give processes a moment to die (exponential backoff)
261            let delay_ms = super::KILL_PROCESSES_INITIAL_DELAY_MS << iteration;
262            std::thread::sleep(std::time::Duration::from_millis(delay_ms));
263        }
264        // Get info about remaining processes for the warning
265        let proc_info = Self::get_process_info(sandbox);
266        warn!(
267            max_retries = super::KILL_PROCESSES_MAX_RETRIES,
268            remaining = %proc_info,
269            "Gave up killing processes after max retries"
270        );
271    }
272
273    /// Get info about processes using files in a directory.
274    fn get_process_info(sandbox: &Path) -> String {
275        let mut info = Vec::new();
276        if let Ok(procs) = procfs::process::all_processes() {
277            for proc in procs.flatten() {
278                if Self::process_uses_path(&proc, sandbox).is_some() {
279                    let cmdline = proc
280                        .cmdline()
281                        .map(|c| c.join(" "))
282                        .unwrap_or_else(|_| String::from("?"));
283                    info.push(format!("pid={} cmd='{}'", proc.pid, cmdline));
284                }
285            }
286        }
287        if info.is_empty() { String::from("(none)") } else { info.join(", ") }
288    }
289
290    /// Check if a process has any references to paths under the given directory.
291    /// Returns Some(reason) describing why the process matches, or None.
292    fn process_uses_path(
293        proc: &procfs::process::Process,
294        dir: &Path,
295    ) -> Option<String> {
296        // Check cwd
297        if let Ok(cwd) = proc.cwd() {
298            if cwd.starts_with(dir) {
299                return Some(format!("cwd={}", cwd.display()));
300            }
301        }
302
303        // Check root (chroot)
304        if let Ok(root) = proc.root() {
305            if root.starts_with(dir) {
306                return Some(format!("root={}", root.display()));
307            }
308        }
309
310        // Check all open file descriptors
311        if let Ok(fds) = proc.fd() {
312            for fd in fds.flatten() {
313                if let procfs::process::FDTarget::Path(path) = fd.target {
314                    if path.starts_with(dir) {
315                        return Some(format!("fd={}", path.display()));
316                    }
317                }
318            }
319        }
320
321        None
322    }
323}