1use 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
37pub 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 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
132pub 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 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 pub fn bind_rw(&mut self, path: impl Into<PathBuf>) -> &mut Self {
248 self.rw_binds.push(path.into());
249 self
250 }
251
252 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}