earl_protocol_bash/
sandbox.rs1use std::path::{Component, Path};
2
3use anyhow::Result;
4use tokio::process::Command;
5
6use crate::ResolvedBashSandbox;
7
8pub fn sandbox_available() -> bool {
10 #[cfg(target_os = "linux")]
11 {
12 which("bwrap")
13 }
14 #[cfg(target_os = "macos")]
15 {
16 which("sandbox-exec")
17 }
18 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
19 {
20 false
21 }
22}
23
24pub fn sandbox_tool_name() -> &'static str {
26 #[cfg(target_os = "linux")]
27 {
28 "bwrap (bubblewrap)"
29 }
30 #[cfg(target_os = "macos")]
31 {
32 "sandbox-exec"
33 }
34 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
35 {
36 "unsupported"
37 }
38}
39
40pub fn build_sandboxed_command(
48 script: &str,
49 env: &[(String, String)],
50 cwd: Option<&str>,
51 sandbox: &ResolvedBashSandbox,
52) -> Result<Command> {
53 #[cfg(target_os = "linux")]
54 {
55 build_linux_command(script, env, cwd, sandbox)
56 }
57 #[cfg(target_os = "macos")]
58 {
59 build_macos_command(script, env, cwd, sandbox)
60 }
61 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
62 {
63 let _ = (script, env, cwd, sandbox);
64 anyhow::bail!(
65 "bash sandbox is not supported on this platform; \
66 only Linux (bwrap) and macOS (sandbox-exec) are supported"
67 );
68 }
69}
70
71#[cfg(target_os = "linux")]
74fn build_linux_command(
75 script: &str,
76 env: &[(String, String)],
77 cwd: Option<&str>,
78 sandbox: &ResolvedBashSandbox,
79) -> Result<Command> {
80 let mut cmd = Command::new("bwrap");
81
82 for dir in &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc"] {
84 if Path::new(dir).exists() {
85 cmd.args(["--ro-bind", dir, dir]);
86 }
87 }
88
89 cmd.args(["--tmpfs", "/tmp"]);
90 cmd.args(["--dev", "/dev"]);
91 cmd.args(["--proc", "/proc"]);
92
93 cmd.arg("--unshare-pid");
95 cmd.arg("--unshare-ipc");
96 cmd.arg("--unshare-uts");
97 if !sandbox.network {
98 cmd.arg("--unshare-net");
99 }
100 cmd.arg("--die-with-parent");
101
102 if let Some(dir) = cwd {
103 validate_sandbox_cwd(dir)?;
104 cmd.args(["--ro-bind", dir, dir]);
106 cmd.args(["--chdir", dir]);
107
108 for writable in &sandbox.writable_paths {
110 let full_path = Path::new(dir).join(writable);
111 let full_str = full_path.to_string_lossy();
112 if full_path.exists() {
113 cmd.args(["--bind", &full_str, &full_str]);
114 }
115 }
116 }
117
118 cmd.args(["--", "bash", "-c", script]);
119 for (key, value) in env {
120 cmd.env(key, value);
123 }
124 Ok(cmd)
125}
126
127#[cfg(target_os = "macos")]
130fn build_macos_command(
131 script: &str,
132 env: &[(String, String)],
133 cwd: Option<&str>,
134 sandbox: &ResolvedBashSandbox,
135) -> Result<Command> {
136 let profile = build_seatbelt_profile(cwd, sandbox);
137 let mut cmd = Command::new("sandbox-exec");
138 cmd.args(["-p", &profile, "bash", "-c", script]);
139 for (key, value) in env {
140 cmd.env(key, value);
143 }
144 if let Some(dir) = cwd {
145 validate_sandbox_cwd(dir)?;
146 cmd.current_dir(dir);
147 }
148 Ok(cmd)
149}
150
151#[cfg(target_os = "macos")]
152fn build_seatbelt_profile(cwd: Option<&str>, sandbox: &ResolvedBashSandbox) -> String {
153 let mut profile = String::from(
154 "(version 1)\n\
155 (deny default)\n",
156 );
157
158 if sandbox.network {
160 profile.push_str("(allow network*)\n");
161 } else {
162 profile.push_str("(deny network*)\n");
163 }
164
165 profile.push_str("(allow process-exec)\n");
167 profile.push_str("(allow process-fork)\n");
168 profile.push_str("(allow sysctl-read)\n");
169
170 profile.push_str("(allow file-read*)\n");
174
175 if let Some(dir) = cwd {
176 for writable in &sandbox.writable_paths {
178 let full_path = std::path::Path::new(dir).join(writable);
179 let full_str = full_path.to_string_lossy();
180 profile.push_str(&format!("(allow file-write* (subpath \"{full_str}\"))\n"));
181 }
182 }
183
184 profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
186 profile.push_str("(allow file-write* (literal \"/dev/tty\"))\n");
187
188 for service in &[
192 "com.apple.system.logger",
193 "com.apple.system.notification_center",
194 "com.apple.SecurityServer",
195 "com.apple.CoreServices.coreservicesd",
196 "com.apple.lsd.mapdb",
197 ] {
198 profile.push_str(&format!(
199 "(allow mach-lookup (global-name \"{service}\"))\n"
200 ));
201 }
202
203 profile
204}
205
206pub fn validate_sandbox_cwd(dir: &str) -> Result<()> {
210 if Path::new(dir)
211 .components()
212 .any(|c| matches!(c, Component::ParentDir))
213 {
214 anyhow::bail!("sandbox cwd must not contain `..` path components");
215 }
216 Ok(())
217}
218
219fn which(name: &str) -> bool {
220 std::process::Command::new("which")
221 .arg(name)
222 .stdout(std::process::Stdio::null())
223 .stderr(std::process::Stdio::null())
224 .status()
225 .map(|s| s.success())
226 .unwrap_or(false)
227}