1use super::config::{SandboxConfig, SandboxType};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::process::Stdio;
9use std::time::Duration;
10use tokio::process::Command;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ExecutorResult {
15 pub exit_code: i32,
17 pub stdout: String,
19 pub stderr: String,
21 pub sandboxed: bool,
23 pub sandbox_type: SandboxType,
25 pub duration: Option<u64>,
27}
28
29#[derive(Debug, Clone)]
31pub struct ExecutorOptions {
32 pub command: String,
34 pub args: Vec<String>,
36 pub timeout: Option<u64>,
38 pub env: HashMap<String, String>,
40 pub working_dir: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SandboxCapabilities {
47 pub bubblewrap: bool,
49 pub seatbelt: bool,
51 pub docker: bool,
53 pub resource_limits: bool,
55}
56
57pub async fn execute_in_sandbox(
59 command: &str,
60 args: &[String],
61 config: &SandboxConfig,
62) -> anyhow::Result<ExecutorResult> {
63 let start_time = std::time::Instant::now();
64
65 if !config.enabled || config.sandbox_type == SandboxType::None {
67 return execute_unsandboxed(command, args, config).await;
68 }
69
70 let result = match config.sandbox_type {
72 SandboxType::Docker => execute_in_docker(command, args, config).await,
73 SandboxType::Bubblewrap => {
74 #[cfg(target_os = "linux")]
75 {
76 execute_in_bubblewrap(command, args, config).await
77 }
78 #[cfg(not(target_os = "linux"))]
79 {
80 tracing::warn!("Bubblewrap 仅在 Linux 上可用,回退到无沙箱执行");
81 execute_unsandboxed(command, args, config).await
82 }
83 }
84 SandboxType::Seatbelt => {
85 #[cfg(target_os = "macos")]
86 {
87 execute_in_seatbelt(command, args, config).await
88 }
89 #[cfg(not(target_os = "macos"))]
90 {
91 tracing::warn!("Seatbelt 仅在 macOS 上可用,回退到无沙箱执行");
92 execute_unsandboxed(command, args, config).await
93 }
94 }
95 SandboxType::Firejail => {
96 #[cfg(target_os = "linux")]
97 {
98 execute_in_firejail(command, args, config).await
99 }
100 #[cfg(not(target_os = "linux"))]
101 {
102 tracing::warn!("Firejail 仅在 Linux 上可用,回退到无沙箱执行");
103 execute_unsandboxed(command, args, config).await
104 }
105 }
106 SandboxType::None => execute_unsandboxed(command, args, config).await,
107 };
108
109 result.map(|mut r| {
110 r.duration = Some(start_time.elapsed().as_millis() as u64);
111 r
112 })
113}
114
115async fn execute_unsandboxed(
117 command: &str,
118 args: &[String],
119 config: &SandboxConfig,
120) -> anyhow::Result<ExecutorResult> {
121 let mut cmd = Command::new(command);
122 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
123
124 for (key, value) in &config.environment_variables {
126 cmd.env(key, value);
127 }
128
129 let timeout = config
130 .resource_limits
131 .as_ref()
132 .and_then(|l| l.max_execution_time)
133 .map(Duration::from_millis);
134
135 let output = if let Some(timeout) = timeout {
136 tokio::time::timeout(timeout, cmd.output()).await??
137 } else {
138 cmd.output().await?
139 };
140
141 Ok(ExecutorResult {
142 exit_code: output.status.code().unwrap_or(1),
143 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
144 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
145 sandboxed: false,
146 sandbox_type: SandboxType::None,
147 duration: None,
148 })
149}
150
151async fn execute_in_docker(
153 command: &str,
154 args: &[String],
155 config: &SandboxConfig,
156) -> anyhow::Result<ExecutorResult> {
157 let docker_config = config.docker.as_ref();
158 let image = docker_config
159 .and_then(|d| d.image.as_ref())
160 .map(|s| s.as_str())
161 .unwrap_or("alpine:latest");
162
163 let mut docker_args = vec!["run", "--rm"];
164
165 if let Some(ref limits) = config.resource_limits {
167 if let Some(max_memory) = limits.max_memory {
168 let mem_str = format!("{}m", max_memory / 1024 / 1024);
169 docker_args.push("-m");
170 docker_args.push(Box::leak(mem_str.into_boxed_str()));
171 }
172 }
173
174 if !config.network_access {
176 docker_args.push("--network=none");
177 }
178
179 docker_args.push(image);
180 docker_args.push(command);
181 for arg in args {
182 docker_args.push(arg);
183 }
184
185 let mut cmd = Command::new("docker");
186 cmd.args(&docker_args)
187 .stdout(Stdio::piped())
188 .stderr(Stdio::piped());
189
190 let output = cmd.output().await?;
191
192 Ok(ExecutorResult {
193 exit_code: output.status.code().unwrap_or(1),
194 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
195 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
196 sandboxed: true,
197 sandbox_type: SandboxType::Docker,
198 duration: None,
199 })
200}
201
202#[cfg(target_os = "linux")]
204async fn execute_in_bubblewrap(
205 command: &str,
206 args: &[String],
207 config: &SandboxConfig,
208) -> anyhow::Result<ExecutorResult> {
209 let mut bwrap_args = vec!["--unshare-all".to_string()];
210
211 for path in &config.read_only_paths {
213 bwrap_args.push("--ro-bind".to_string());
214 bwrap_args.push(path.to_string_lossy().to_string());
215 bwrap_args.push(path.to_string_lossy().to_string());
216 }
217
218 for path in &config.writable_paths {
220 bwrap_args.push("--bind".to_string());
221 bwrap_args.push(path.to_string_lossy().to_string());
222 bwrap_args.push(path.to_string_lossy().to_string());
223 }
224
225 if config.allow_dev_access {
227 bwrap_args.push("--dev".to_string());
228 bwrap_args.push("/dev".to_string());
229 }
230
231 if config.allow_proc_access {
233 bwrap_args.push("--proc".to_string());
234 bwrap_args.push("/proc".to_string());
235 }
236
237 if config.die_with_parent {
239 bwrap_args.push("--die-with-parent".to_string());
240 }
241
242 if config.new_session {
244 bwrap_args.push("--new-session".to_string());
245 }
246
247 bwrap_args.push("--".to_string());
248 bwrap_args.push(command.to_string());
249 bwrap_args.extend(args.iter().cloned());
250
251 let mut cmd = Command::new("bwrap");
252 cmd.args(&bwrap_args)
253 .stdout(Stdio::piped())
254 .stderr(Stdio::piped());
255
256 let output = cmd.output().await?;
257
258 Ok(ExecutorResult {
259 exit_code: output.status.code().unwrap_or(1),
260 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
261 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
262 sandboxed: true,
263 sandbox_type: SandboxType::Bubblewrap,
264 duration: None,
265 })
266}
267
268#[cfg(target_os = "macos")]
270async fn execute_in_seatbelt(
271 command: &str,
272 args: &[String],
273 config: &SandboxConfig,
274) -> anyhow::Result<ExecutorResult> {
275 let mut profile = String::from("(version 1)\n(deny default)\n");
277
278 profile.push_str("(allow process-exec)\n");
280
281 for path in &config.read_only_paths {
283 profile.push_str(&format!(
284 "(allow file-read* (subpath \"{}\"))\n",
285 path.display()
286 ));
287 }
288
289 for path in &config.writable_paths {
291 profile.push_str(&format!(
292 "(allow file-write* (subpath \"{}\"))\n",
293 path.display()
294 ));
295 }
296
297 if config.network_access {
299 profile.push_str("(allow network*)\n");
300 }
301
302 let mut cmd = Command::new("sandbox-exec");
303 cmd.args(["-p", &profile, command])
304 .args(args)
305 .stdout(Stdio::piped())
306 .stderr(Stdio::piped());
307
308 let output = cmd.output().await?;
309
310 Ok(ExecutorResult {
311 exit_code: output.status.code().unwrap_or(1),
312 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
313 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
314 sandboxed: true,
315 sandbox_type: SandboxType::Seatbelt,
316 duration: None,
317 })
318}
319
320#[cfg(target_os = "linux")]
322async fn execute_in_firejail(
323 command: &str,
324 args: &[String],
325 config: &SandboxConfig,
326) -> anyhow::Result<ExecutorResult> {
327 let mut firejail_args = vec!["--quiet".to_string()];
328
329 if !config.network_access {
331 firejail_args.push("--net=none".to_string());
332 }
333
334 firejail_args.push("--private-tmp".to_string());
336
337 for path in &config.read_only_paths {
339 firejail_args.push(format!("--read-only={}", path.display()));
340 }
341
342 firejail_args.push("--".to_string());
343 firejail_args.push(command.to_string());
344 firejail_args.extend(args.iter().cloned());
345
346 let mut cmd = Command::new("firejail");
347 cmd.args(&firejail_args)
348 .stdout(Stdio::piped())
349 .stderr(Stdio::piped());
350
351 let output = cmd.output().await?;
352
353 Ok(ExecutorResult {
354 exit_code: output.status.code().unwrap_or(1),
355 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
356 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
357 sandboxed: true,
358 sandbox_type: SandboxType::Firejail,
359 duration: None,
360 })
361}
362
363pub fn detect_best_sandbox() -> SandboxType {
365 #[cfg(target_os = "linux")]
366 {
367 if std::process::Command::new("which")
369 .arg("bwrap")
370 .output()
371 .map(|o| o.status.success())
372 .unwrap_or(false)
373 {
374 return SandboxType::Bubblewrap;
375 }
376 }
377
378 #[cfg(target_os = "macos")]
379 {
380 if std::process::Command::new("which")
382 .arg("sandbox-exec")
383 .output()
384 .map(|o| o.status.success())
385 .unwrap_or(false)
386 {
387 return SandboxType::Seatbelt;
388 }
389 }
390
391 if std::process::Command::new("docker")
393 .arg("version")
394 .output()
395 .map(|o| o.status.success())
396 .unwrap_or(false)
397 {
398 return SandboxType::Docker;
399 }
400
401 SandboxType::None
402}
403
404pub fn get_sandbox_capabilities() -> SandboxCapabilities {
406 let mut caps = SandboxCapabilities {
407 bubblewrap: false,
408 seatbelt: false,
409 docker: false,
410 resource_limits: false,
411 };
412
413 #[cfg(target_os = "linux")]
414 {
415 caps.bubblewrap = std::process::Command::new("which")
416 .arg("bwrap")
417 .output()
418 .map(|o| o.status.success())
419 .unwrap_or(false);
420 caps.resource_limits = true;
421 }
422
423 #[cfg(target_os = "macos")]
424 {
425 caps.seatbelt = std::process::Command::new("which")
426 .arg("sandbox-exec")
427 .output()
428 .map(|o| o.status.success())
429 .unwrap_or(false);
430 caps.resource_limits = true;
431 }
432
433 caps.docker = std::process::Command::new("docker")
434 .arg("version")
435 .output()
436 .map(|o| o.status.success())
437 .unwrap_or(false);
438
439 caps
440}
441
442pub struct SandboxExecutor {
444 config: SandboxConfig,
445}
446
447impl SandboxExecutor {
448 pub fn new(config: SandboxConfig) -> Self {
450 Self { config }
451 }
452
453 pub async fn execute(&self, command: &str, args: &[String]) -> anyhow::Result<ExecutorResult> {
455 execute_in_sandbox(command, args, &self.config).await
456 }
457
458 pub async fn execute_sequence(
460 &self,
461 commands: &[(String, Vec<String>)],
462 ) -> anyhow::Result<Vec<ExecutorResult>> {
463 let mut results = Vec::new();
464
465 for (command, args) in commands {
466 let result = self.execute(command, args).await?;
467 let failed = result.exit_code != 0;
468 results.push(result);
469
470 if failed {
471 break;
472 }
473 }
474
475 Ok(results)
476 }
477
478 pub async fn execute_parallel(
480 &self,
481 commands: &[(String, Vec<String>)],
482 ) -> anyhow::Result<Vec<ExecutorResult>> {
483 let futures: Vec<_> = commands
484 .iter()
485 .map(|(cmd, args)| self.execute(cmd, args))
486 .collect();
487
488 let results = futures::future::try_join_all(futures).await?;
489 Ok(results)
490 }
491
492 pub fn update_config(&mut self, config: SandboxConfig) {
494 self.config = config;
495 }
496
497 pub fn get_config(&self) -> &SandboxConfig {
499 &self.config
500 }
501}