Skip to main content

sandbox_command/
lib.rs

1//! A command-builder replacement that launches children through the host OS sandbox.
2//!
3//! This crate intentionally stops at process creation. Callers decide whether
4//! to wait, collect output, or stream the child's pipes themselves.
5
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    ffi::{OsStr, OsString},
9    path::{Path, PathBuf},
10    process::{ExitStatus, Output, Stdio},
11    sync::{
12        OnceLock,
13        atomic::{AtomicUsize, Ordering},
14    },
15};
16use thiserror::Error;
17use tokio::{
18    io::AsyncReadExt,
19    process::{Child, ChildStderr, ChildStdin, ChildStdout, Command},
20};
21
22#[derive(Debug, Error)]
23pub enum SandboxCommandError {
24    #[error("sandbox initialization failed: {0}")]
25    Initialization(String),
26    #[error("sandbox command failed to start: {0}")]
27    Spawn(#[source] std::io::Error),
28    #[error("sandbox command path is invalid: {0}")]
29    InvalidPath(String),
30}
31
32struct PreparedCommand {
33    command: Command,
34    cleanup: Option<PathBuf>,
35}
36
37/// A spawned sandboxed process.
38///
39/// This is intentionally a small child-process wrapper rather than a stream
40/// abstraction. It exists so macOS sandbox profiles can remain on disk until
41/// `sandbox-exec` has finished with them; callers still choose whether to
42/// consume pipes, wait, or collect output.
43pub struct SandboxChild {
44    child: Child,
45    pub stdin: Option<ChildStdin>,
46    pub stdout: Option<ChildStdout>,
47    pub stderr: Option<ChildStderr>,
48    cleanup: Option<PathBuf>,
49}
50
51impl SandboxChild {
52    pub fn id(&self) -> Option<u32> {
53        self.child.id()
54    }
55
56    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
57        self.stdin.take()
58    }
59
60    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
61        self.stdout.take()
62    }
63
64    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
65        self.stderr.take()
66    }
67
68    pub async fn kill(&mut self) -> std::io::Result<()> {
69        self.child.kill().await
70    }
71
72    pub fn start_kill(&mut self) -> std::io::Result<()> {
73        self.child.start_kill()
74    }
75
76    pub async fn wait(&mut self) -> std::io::Result<ExitStatus> {
77        let result = self.child.wait().await;
78        if result.is_ok() {
79            self.clean_up();
80        }
81        result
82    }
83
84    pub fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
85        let result = self.child.try_wait();
86        if matches!(result, Ok(Some(_))) {
87            self.clean_up();
88        }
89        result
90    }
91
92    pub async fn wait_with_output(mut self) -> std::io::Result<Output> {
93        // Match Tokio's `Child::wait_with_output`: close the parent's stdin
94        // handle so a child waiting for EOF cannot deadlock the collection.
95        let _ = self.stdin.take();
96        let mut stdout = self.stdout.take();
97        let mut stderr = self.stderr.take();
98        let stdout_read = async {
99            let mut output = Vec::new();
100            if let Some(stdout) = stdout.as_mut() {
101                stdout.read_to_end(&mut output).await?;
102            }
103            Ok::<_, std::io::Error>(output)
104        };
105        let stderr_read = async {
106            let mut output = Vec::new();
107            if let Some(stderr) = stderr.as_mut() {
108                stderr.read_to_end(&mut output).await?;
109            }
110            Ok::<_, std::io::Error>(output)
111        };
112        let (stdout, stderr) = tokio::try_join!(stdout_read, stderr_read)?;
113        let status = self.wait().await?;
114        Ok(Output {
115            status,
116            stdout,
117            stderr,
118        })
119    }
120
121    fn clean_up(&mut self) {
122        remove_profile(self.cleanup.take());
123    }
124}
125
126impl Drop for SandboxChild {
127    fn drop(&mut self) {
128        self.clean_up();
129    }
130}
131
132/// A command builder with the familiar `Command` interface plus sandbox mount
133/// controls. Output handling is deliberately left to the caller.
134pub struct SandboxCommand {
135    program: OsString,
136    args: Vec<OsString>,
137    env: BTreeMap<OsString, OsString>,
138    removed_env: BTreeSet<OsString>,
139    clear_env: bool,
140    current_dir: Option<PathBuf>,
141    stdin: Option<Stdio>,
142    stdout: Option<Stdio>,
143    stderr: Option<Stdio>,
144    kill_on_drop: bool,
145    rw_binds: Vec<PathBuf>,
146    ro_binds: Vec<PathBuf>,
147}
148
149impl SandboxCommand {
150    /// Verify that the platform sandbox is available. Call this during
151    /// application startup; the result is cached for later commands.
152    pub fn initialize() -> Result<(), SandboxCommandError> {
153        platform().map(|_| ())
154    }
155
156    pub fn new(program: impl AsRef<OsStr>) -> Self {
157        Self {
158            program: program.as_ref().to_owned(),
159            args: Vec::new(),
160            env: BTreeMap::new(),
161            removed_env: BTreeSet::new(),
162            clear_env: false,
163            current_dir: None,
164            stdin: None,
165            stdout: None,
166            stderr: None,
167            kill_on_drop: false,
168            rw_binds: Vec::new(),
169            ro_binds: Vec::new(),
170        }
171    }
172
173    pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
174        self.args.push(arg.as_ref().to_owned());
175        self
176    }
177
178    pub fn args<I, S>(&mut self, args: I) -> &mut Self
179    where
180        I: IntoIterator<Item = S>,
181        S: AsRef<OsStr>,
182    {
183        self.args
184            .extend(args.into_iter().map(|arg| arg.as_ref().to_owned()));
185        self
186    }
187
188    pub fn env(&mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> &mut Self {
189        let key = key.as_ref().to_owned();
190        self.removed_env.remove(&key);
191        self.env.insert(key, value.as_ref().to_owned());
192        self
193    }
194
195    pub fn envs<I, K, V>(&mut self, envs: I) -> &mut Self
196    where
197        I: IntoIterator<Item = (K, V)>,
198        K: AsRef<OsStr>,
199        V: AsRef<OsStr>,
200    {
201        for (key, value) in envs {
202            self.env(key, value);
203        }
204        self
205    }
206
207    pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
208        let key = key.as_ref().to_owned();
209        self.env.remove(&key);
210        self.removed_env.insert(key);
211        self
212    }
213
214    pub fn env_clear(&mut self) -> &mut Self {
215        self.clear_env = true;
216        self.env.clear();
217        self.removed_env.clear();
218        self
219    }
220
221    pub fn current_dir(&mut self, path: impl Into<PathBuf>) -> &mut Self {
222        self.current_dir = Some(path.into());
223        self
224    }
225
226    pub fn stdin(&mut self, cfg: Stdio) -> &mut Self {
227        self.stdin = Some(cfg);
228        self
229    }
230
231    pub fn stdout(&mut self, cfg: Stdio) -> &mut Self {
232        self.stdout = Some(cfg);
233        self
234    }
235
236    pub fn stderr(&mut self, cfg: Stdio) -> &mut Self {
237        self.stderr = Some(cfg);
238        self
239    }
240
241    pub fn kill_on_drop(&mut self, kill: bool) -> &mut Self {
242        self.kill_on_drop = kill;
243        self
244    }
245
246    /// Add a writable path at the same location inside the sandbox.
247    pub fn bind_rw(&mut self, path: impl Into<PathBuf>) -> &mut Self {
248        self.rw_binds.push(path.into());
249        self
250    }
251
252    /// Add a read-only path at the same location inside the sandbox.
253    pub fn bind_ro(&mut self, path: impl Into<PathBuf>) -> &mut Self {
254        self.ro_binds.push(path.into());
255        self
256    }
257
258    pub fn spawn(&mut self) -> Result<SandboxChild, SandboxCommandError> {
259        let cwd = self
260            .current_dir
261            .clone()
262            .unwrap_or(std::env::current_dir().map_err(SandboxCommandError::Spawn)?);
263        let cwd = cwd.canonicalize().map_err(|error| {
264            SandboxCommandError::InvalidPath(format!("{}: {error}", cwd.display()))
265        })?;
266        let PreparedCommand {
267            mut command,
268            cleanup,
269        } = platform()?.command(self, &cwd)?;
270
271        command.current_dir(&cwd).kill_on_drop(self.kill_on_drop);
272        if let Some(stdin) = self.stdin.take() {
273            command.stdin(stdin);
274        }
275        if let Some(stdout) = self.stdout.take() {
276            command.stdout(stdout);
277        }
278        if let Some(stderr) = self.stderr.take() {
279            command.stderr(stderr);
280        }
281        if self.clear_env {
282            command.env_clear();
283        }
284        command.envs(&self.env);
285        for key in &self.removed_env {
286            command.env_remove(key);
287        }
288
289        let mut child = match command.spawn() {
290            Ok(child) => child,
291            Err(error) => {
292                remove_profile(cleanup);
293                return Err(SandboxCommandError::Spawn(error));
294            }
295        };
296        Ok(SandboxChild {
297            stdin: child.stdin.take(),
298            stdout: child.stdout.take(),
299            stderr: child.stderr.take(),
300            child,
301            cleanup,
302        })
303    }
304}
305
306fn remove_profile(path: Option<PathBuf>) {
307    if let Some(path) = path {
308        let _ = std::fs::remove_file(path);
309    }
310}
311
312enum Platform {
313    #[cfg(target_os = "linux")]
314    Linux { bwrap: PathBuf },
315    #[cfg(target_os = "macos")]
316    Macos,
317}
318
319impl Platform {
320    fn command(
321        &self,
322        command: &SandboxCommand,
323        cwd: &Path,
324    ) -> Result<PreparedCommand, SandboxCommandError> {
325        match self {
326            #[cfg(target_os = "linux")]
327            Self::Linux { bwrap } => linux_command(bwrap, command, cwd),
328            #[cfg(target_os = "macos")]
329            Self::Macos => macos_command(command, cwd),
330        }
331    }
332}
333
334fn platform() -> Result<&'static Platform, SandboxCommandError> {
335    static PLATFORM: OnceLock<Result<Platform, String>> = OnceLock::new();
336    PLATFORM
337        .get_or_init(|| detect_platform().map_err(|error| error.to_string()))
338        .as_ref()
339        .map_err(|error| SandboxCommandError::Initialization(error.clone()))
340}
341
342#[cfg(target_os = "linux")]
343fn detect_platform() -> Result<Platform, SandboxCommandError> {
344    let bwrap = find_program("bwrap").ok_or_else(|| {
345        SandboxCommandError::Initialization(
346            "required sandbox executable `bwrap` was not found".into(),
347        )
348    })?;
349    Ok(Platform::Linux { bwrap })
350}
351
352#[cfg(target_os = "macos")]
353fn detect_platform() -> Result<Platform, SandboxCommandError> {
354    if !Path::new("/usr/bin/sandbox-exec").is_file() {
355        return Err(SandboxCommandError::Initialization(
356            "required sandbox executable `/usr/bin/sandbox-exec` was not found".into(),
357        ));
358    }
359    Ok(Platform::Macos)
360}
361
362#[cfg(not(any(target_os = "linux", target_os = "macos")))]
363fn detect_platform() -> Result<Platform, SandboxCommandError> {
364    Err(SandboxCommandError::Initialization(
365        "sandbox-command supports only Linux and macOS".into(),
366    ))
367}
368
369#[cfg(target_os = "linux")]
370fn find_program(program: &str) -> Option<PathBuf> {
371    std::env::var_os("PATH")?
372        .split_paths()
373        .map(|dir| dir.join(program))
374        .find(|path| path.is_file())
375}
376
377#[cfg(target_os = "linux")]
378fn linux_command(
379    bwrap: &Path,
380    command: &SandboxCommand,
381    cwd: &Path,
382) -> Result<PreparedCommand, SandboxCommandError> {
383    let mut wrapped = Command::new(bwrap);
384    let cwd = cwd.to_string_lossy();
385    wrapped.args([
386        "--bind",
387        &cwd,
388        &cwd,
389        "--chdir",
390        &cwd,
391        "--ro-bind",
392        "/usr",
393        "/usr",
394        "--symlink",
395        "usr/lib",
396        "/lib",
397        "--symlink",
398        "usr/bin",
399        "/bin",
400        "--symlink",
401        "usr/sbin",
402        "/sbin",
403        "--proc",
404        "/proc",
405        "--dev",
406        "/dev",
407        "--dir",
408        "/tmp",
409        "--share-net",
410        "--die-with-parent",
411    ]);
412    if Path::new("/etc/resolv.conf").exists() {
413        wrapped.args(["--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf"]);
414    }
415    if Path::new("/etc/alternatives").is_dir() {
416        wrapped.args(["--ro-bind", "/etc/alternatives", "/etc/alternatives"]);
417    }
418    for path in &command.ro_binds {
419        let path = path.to_string_lossy();
420        wrapped.args(["--ro-bind", &path, &path]);
421    }
422    for path in &command.rw_binds {
423        let path = path.to_string_lossy();
424        wrapped.args(["--bind", &path, &path]);
425    }
426    wrapped.arg("--").arg(&command.program).args(&command.args);
427    Ok(PreparedCommand {
428        command: wrapped,
429        cleanup: None,
430    })
431}
432
433#[cfg(target_os = "macos")]
434fn macos_command(
435    command: &SandboxCommand,
436    cwd: &Path,
437) -> Result<PreparedCommand, SandboxCommandError> {
438    static PROFILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
439    let profile_path = std::env::temp_dir().join(format!(
440        "sandbox-command-{}-{}.sb",
441        std::process::id(),
442        PROFILE_COUNTER.fetch_add(1, Ordering::Relaxed)
443    ));
444    let profile = profile_for(cwd, &command.rw_binds);
445    std::fs::write(&profile_path, profile).map_err(|error| {
446        SandboxCommandError::Initialization(format!("cannot write sandbox profile: {error}"))
447    })?;
448    let mut wrapped = Command::new("/usr/bin/sandbox-exec");
449    wrapped
450        .arg("-f")
451        .arg(profile_path.as_os_str())
452        .arg(&command.program)
453        .args(&command.args);
454    Ok(PreparedCommand {
455        command: wrapped,
456        cleanup: Some(profile_path),
457    })
458}
459
460#[cfg(target_os = "macos")]
461fn profile_for(cwd: &Path, rw_binds: &[PathBuf]) -> String {
462    let quote = |path: &Path| {
463        format!(
464            "\"{}\"",
465            path.to_string_lossy()
466                .replace('\\', "\\\\")
467                .replace('"', "\\\"")
468        )
469    };
470    let mut profile = format!(
471        "(version 1)\n(deny default)\n(allow network*)\n(allow process-exec)\n(allow process-fork)\n(allow signal)\n(allow sysctl-read)\n(allow file-read-data file-read-metadata file-map-executable (subpath \"/\"))\n(allow file-write* (subpath \"/dev/null\"))\n(allow file-write* (subpath {}))\n(allow file-write* (subpath \"/private/tmp\"))\n(allow file-write* (subpath \"/tmp\"))\n(allow file-write* (subpath \"/var/folders\"))\n",
472        quote(cwd)
473    );
474    for path in rw_binds {
475        profile.push_str(&format!("(allow file-write* (subpath {}))\n", quote(path)));
476    }
477    profile
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[tokio::test]
485    async fn spawns_a_sandboxed_child() {
486        let root = tempfile::tempdir().unwrap();
487        let mut command = SandboxCommand::new("/usr/bin/printf");
488        command
489            .current_dir(root.path())
490            .arg("hello")
491            .stdout(Stdio::piped());
492        let child = match command.spawn() {
493            Ok(child) => child,
494            Err(SandboxCommandError::Initialization(_)) => return,
495            Err(error) => panic!("unexpected error: {error}"),
496        };
497        let output = child.wait_with_output().await.unwrap();
498        if !output.status.success() && !cfg!(target_os = "macos") {
499            return;
500        }
501        assert!(output.status.success());
502        assert_eq!(output.stdout, b"hello");
503    }
504}