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);
121 }
122 Ok(cmd)
123}
124
125#[cfg(target_os = "macos")]
128fn build_macos_command(
129 script: &str,
130 env: &[(String, String)],
131 cwd: Option<&str>,
132 sandbox: &ResolvedBashSandbox,
133) -> Result<Command> {
134 let profile = build_seatbelt_profile(cwd, sandbox);
135 let mut cmd = Command::new("sandbox-exec");
136 cmd.args(["-p", &profile, "bash", "-c", script]);
137 for (key, value) in env {
138 cmd.env(key, value);
139 }
140 if let Some(dir) = cwd {
141 validate_sandbox_cwd(dir)?;
142 cmd.current_dir(dir);
143 }
144 Ok(cmd)
145}
146
147#[cfg(target_os = "macos")]
148fn build_seatbelt_profile(cwd: Option<&str>, sandbox: &ResolvedBashSandbox) -> String {
149 let mut profile = String::from(
150 "(version 1)\n\
151 (deny default)\n",
152 );
153
154 if sandbox.network {
156 profile.push_str("(allow network*)\n");
157 } else {
158 profile.push_str("(deny network*)\n");
159 }
160
161 profile.push_str("(allow process-exec)\n");
163 profile.push_str("(allow process-fork)\n");
164 profile.push_str("(allow sysctl-read)\n");
165
166 profile.push_str("(allow file-read*)\n");
170
171 if let Some(dir) = cwd {
172 for writable in &sandbox.writable_paths {
174 let full_path = std::path::Path::new(dir).join(writable);
175 let full_str = full_path.to_string_lossy();
176 profile.push_str(&format!("(allow file-write* (subpath \"{full_str}\"))\n"));
177 }
178 }
179
180 profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
182 profile.push_str("(allow file-write* (literal \"/dev/tty\"))\n");
183
184 for service in &[
188 "com.apple.system.logger",
189 "com.apple.system.notification_center",
190 "com.apple.SecurityServer",
191 "com.apple.CoreServices.coreservicesd",
192 "com.apple.lsd.mapdb",
193 ] {
194 profile.push_str(&format!(
195 "(allow mach-lookup (global-name \"{service}\"))\n"
196 ));
197 }
198
199 profile
200}
201
202pub fn validate_sandbox_cwd(dir: &str) -> Result<()> {
206 if Path::new(dir)
207 .components()
208 .any(|c| matches!(c, Component::ParentDir))
209 {
210 anyhow::bail!("sandbox cwd must not contain `..` path components");
211 }
212 Ok(())
213}
214
215fn which(name: &str) -> bool {
216 std::process::Command::new("which")
217 .arg(name)
218 .stdout(std::process::Stdio::null())
219 .stderr(std::process::Stdio::null())
220 .status()
221 .map(|s| s.success())
222 .unwrap_or(false)
223}