sandbox_rs/
controller.rs

1//! Main sandbox controller
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::time::{Duration, Instant};
7
8use log::warn;
9use nix::sys::signal::{Signal, kill};
10use nix::unistd::Pid;
11
12use crate::errors::{Result, SandboxError};
13use crate::execution::ProcessStream;
14use crate::execution::process::{ProcessConfig, ProcessExecutor};
15use crate::isolation::namespace::NamespaceConfig;
16use crate::isolation::seccomp::SeccompProfile;
17use crate::resources::cgroup::{Cgroup, CgroupConfig};
18use crate::utils;
19
20/// Sandbox configuration
21#[derive(Debug, Clone)]
22pub struct SandboxConfig {
23    /// Root directory for sandbox
24    pub root: PathBuf,
25    /// Memory limit in bytes
26    pub memory_limit: Option<u64>,
27    /// CPU quota (microseconds)
28    pub cpu_quota: Option<u64>,
29    /// CPU period (microseconds)
30    pub cpu_period: Option<u64>,
31    /// Maximum PIDs
32    pub max_pids: Option<u32>,
33    /// Seccomp profile
34    pub seccomp_profile: SeccompProfile,
35    /// Namespace configuration
36    pub namespace_config: NamespaceConfig,
37    /// Timeout
38    pub timeout: Option<Duration>,
39    /// Unique sandbox ID
40    pub id: String,
41}
42
43impl Default for SandboxConfig {
44    fn default() -> Self {
45        Self {
46            root: PathBuf::from("/var/lib/sandbox"),
47            memory_limit: None,
48            cpu_quota: None,
49            cpu_period: None,
50            max_pids: None,
51            seccomp_profile: SeccompProfile::Minimal,
52            namespace_config: NamespaceConfig::default(),
53            timeout: None,
54            id: "default".to_string(),
55        }
56    }
57}
58
59impl SandboxConfig {
60    /// Validate configuration
61    pub fn validate(&self) -> Result<()> {
62        utils::require_root()?;
63
64        self.validate_invariants()
65    }
66
67    fn validate_invariants(&self) -> Result<()> {
68        if self.id.is_empty() {
69            return Err(SandboxError::InvalidConfig(
70                "Sandbox ID cannot be empty".to_string(),
71            ));
72        }
73
74        if self.namespace_config.enabled_count() == 0 {
75            return Err(SandboxError::InvalidConfig(
76                "At least one namespace must be enabled".to_string(),
77            ));
78        }
79
80        Ok(())
81    }
82}
83
84/// Builder pattern for sandbox creation
85pub struct SandboxBuilder {
86    config: SandboxConfig,
87}
88
89impl SandboxBuilder {
90    /// Create new builder
91    pub fn new(id: &str) -> Self {
92        Self {
93            config: SandboxConfig {
94                id: id.to_string(),
95                ..Default::default()
96            },
97        }
98    }
99
100    /// Set memory limit
101    pub fn memory_limit(mut self, bytes: u64) -> Self {
102        self.config.memory_limit = Some(bytes);
103        self
104    }
105
106    /// Set memory limit from string (e.g., "100M")
107    pub fn memory_limit_str(self, s: &str) -> Result<Self> {
108        let bytes = utils::parse_memory_size(s)?;
109        Ok(self.memory_limit(bytes))
110    }
111
112    /// Set CPU quota
113    pub fn cpu_quota(mut self, quota: u64, period: u64) -> Self {
114        self.config.cpu_quota = Some(quota);
115        self.config.cpu_period = Some(period);
116        self
117    }
118
119    /// Set CPU limit by percentage (0-100)
120    pub fn cpu_limit_percent(self, percent: u32) -> Self {
121        if percent == 0 || percent > 100 {
122            return self;
123        }
124        let quota = (percent as u64) * 1000; // percent * period/100 with period=100000
125        let period = 100000;
126        self.cpu_quota(quota, period)
127    }
128
129    /// Set maximum PIDs
130    pub fn max_pids(mut self, max: u32) -> Self {
131        self.config.max_pids = Some(max);
132        self
133    }
134
135    /// Set seccomp profile
136    pub fn seccomp_profile(mut self, profile: SeccompProfile) -> Self {
137        self.config.seccomp_profile = profile;
138        self
139    }
140
141    /// Set root directory
142    pub fn root(mut self, path: impl AsRef<Path>) -> Self {
143        self.config.root = path.as_ref().to_path_buf();
144        self
145    }
146
147    /// Set timeout
148    pub fn timeout(mut self, duration: Duration) -> Self {
149        self.config.timeout = Some(duration);
150        self
151    }
152
153    /// Set namespace configuration
154    pub fn namespaces(mut self, config: NamespaceConfig) -> Self {
155        self.config.namespace_config = config;
156        self
157    }
158
159    /// Build sandbox
160    pub fn build(self) -> Result<Sandbox> {
161        self.config.validate()?;
162        Sandbox::new(self.config)
163    }
164}
165
166/// Sandbox execution result
167#[derive(Debug, Clone)]
168pub struct SandboxResult {
169    /// Exit code
170    pub exit_code: i32,
171    /// Signal that killed process (if any)
172    pub signal: Option<i32>,
173    /// Whether timeout occurred
174    pub timed_out: bool,
175    /// Memory usage in bytes
176    pub memory_peak: u64,
177    /// CPU time in microseconds
178    pub cpu_time_us: u64,
179    /// Wall clock time in seconds
180    pub wall_time_ms: u64,
181}
182
183impl SandboxResult {
184    /// Check if process was killed by seccomp (SIGSYS - signal 31)
185    /// Returns true if exit code is 159 (128 + 31)
186    pub fn killed_by_seccomp(&self) -> bool {
187        self.exit_code == 159
188    }
189
190    /// Get human-readable error message if process failed due to seccomp
191    pub fn seccomp_error(&self) -> Option<&'static str> {
192        if self.killed_by_seccomp() {
193            Some("The action requires more permissions than were granted.")
194        } else {
195            None
196        }
197    }
198
199    /// Convert to Result, returning error if process was killed by seccomp
200    pub fn check_seccomp_error(&self) -> crate::errors::Result<&SandboxResult> {
201        if self.killed_by_seccomp() {
202            Err(SandboxError::PermissionDenied(
203                "The seccomp profile is too restrictive for this operation. \
204                 Try using a less restrictive profile (e.g., SeccompProfile::Compute or SeccompProfile::Unrestricted)"
205                    .to_string(),
206            ))
207        } else {
208            Ok(self)
209        }
210    }
211}
212
213/// Active sandbox
214pub struct Sandbox {
215    config: SandboxConfig,
216    pid: Option<Pid>,
217    cgroup: Option<Cgroup>,
218    start_time: Option<Instant>,
219}
220
221impl Sandbox {
222    /// Create new sandbox
223    fn new(config: SandboxConfig) -> Result<Self> {
224        // Create root directory
225        fs::create_dir_all(&config.root).map_err(|e| {
226            SandboxError::Io(std::io::Error::other(format!(
227                "Failed to create root directory: {}",
228                e
229            )))
230        })?;
231
232        Ok(Self {
233            config,
234            pid: None,
235            cgroup: None,
236            start_time: None,
237        })
238    }
239
240    /// Get sandbox ID
241    pub fn id(&self) -> &str {
242        &self.config.id
243    }
244
245    /// Get sandbox root
246    pub fn root(&self) -> &Path {
247        &self.config.root
248    }
249
250    /// Check if sandbox is running
251    pub fn is_running(&self) -> bool {
252        self.pid.is_some()
253    }
254
255    /// Run program in sandbox
256    pub fn run(&mut self, program: &str, args: &[&str]) -> Result<SandboxResult> {
257        if self.is_running() {
258            return Err(SandboxError::AlreadyRunning);
259        }
260
261        self.start_time = Some(Instant::now());
262
263        if utils::is_root() {
264            let cgroup_name = format!("sandbox-{}", self.config.id);
265            let cgroup = Cgroup::new(&cgroup_name, Pid::from_raw(std::process::id() as i32))?;
266
267            let cgroup_config = CgroupConfig {
268                memory_limit: self.config.memory_limit,
269                cpu_quota: self.config.cpu_quota,
270                cpu_period: self.config.cpu_period,
271                max_pids: self.config.max_pids,
272                cpu_weight: None,
273            };
274            cgroup.apply_config(&cgroup_config)?;
275
276            self.cgroup = Some(cgroup);
277        } else {
278            warn!(
279                "Skipping cgroup configuration for sandbox {} (not running as root)",
280                self.config.id
281            );
282        }
283
284        let process_config = ProcessConfig {
285            program: program.to_string(),
286            args: args.iter().map(|s| s.to_string()).collect(),
287            env: Vec::new(),
288            cwd: None,
289            chroot_dir: None,
290            uid: None,
291            gid: None,
292            seccomp: Some(crate::isolation::seccomp::SeccompFilter::from_profile(
293                self.config.seccomp_profile.clone(),
294            )),
295            inherit_env: true,
296        };
297
298        // Execute with namespace isolation
299        if utils::is_root() {
300            // Real isolation with namespaces
301            let process_result =
302                ProcessExecutor::execute(process_config, self.config.namespace_config.clone())?;
303
304            self.pid = Some(process_result.pid);
305
306            let wall_time_ms = self.start_time.unwrap().elapsed().as_millis() as u64;
307
308            // Get peak memory from cgroup if available
309            let (memory_peak, _) = self.get_resource_usage().unwrap_or((0, 0));
310
311            Ok(SandboxResult {
312                exit_code: process_result.exit_status,
313                signal: process_result.signal,
314                timed_out: false,
315                memory_peak,
316                cpu_time_us: process_result.exec_time_ms * 1000,
317                wall_time_ms,
318            })
319        } else {
320            warn!("Running without full isolation (not root). Use sudo for production sandboxes.");
321            let output = Command::new(program)
322                .args(args)
323                .output()
324                .map_err(SandboxError::Io)?;
325
326            let exit_code = output.status.code().unwrap_or(-1);
327            let wall_time_ms = self.start_time.unwrap().elapsed().as_millis() as u64;
328
329            Ok(SandboxResult {
330                exit_code,
331                signal: None,
332                timed_out: false,
333                memory_peak: 0,
334                cpu_time_us: 0,
335                wall_time_ms,
336            })
337        }
338    }
339
340    /// Run program with streaming output
341    pub fn run_with_stream(
342        &mut self,
343        program: &str,
344        args: &[&str],
345    ) -> Result<(SandboxResult, ProcessStream)> {
346        if self.is_running() {
347            return Err(SandboxError::AlreadyRunning);
348        }
349
350        self.start_time = Some(Instant::now());
351
352        let cgroup_name = format!("sandbox-{}", self.config.id);
353        let cgroup = Cgroup::new(&cgroup_name, Pid::from_raw(std::process::id() as i32))?;
354
355        let cgroup_config = CgroupConfig {
356            memory_limit: self.config.memory_limit,
357            cpu_quota: self.config.cpu_quota,
358            cpu_period: self.config.cpu_period,
359            max_pids: self.config.max_pids,
360            cpu_weight: None,
361        };
362        cgroup.apply_config(&cgroup_config)?;
363
364        self.cgroup = Some(cgroup);
365
366        let process_config = ProcessConfig {
367            program: program.to_string(),
368            args: args.iter().map(|s| s.to_string()).collect(),
369            env: Vec::new(),
370            cwd: None,
371            chroot_dir: None,
372            uid: None,
373            gid: None,
374            seccomp: Some(crate::isolation::seccomp::SeccompFilter::from_profile(
375                self.config.seccomp_profile.clone(),
376            )),
377            inherit_env: true,
378        };
379
380        let (process_result, stream) = ProcessExecutor::execute_with_stream(
381            process_config,
382            self.config.namespace_config.clone(),
383            true,
384        )?;
385
386        self.pid = Some(process_result.pid);
387
388        let wall_time_ms = self.start_time.unwrap().elapsed().as_millis() as u64;
389        let (memory_peak, _) = self.get_resource_usage().unwrap_or((0, 0));
390
391        let sandbox_result = SandboxResult {
392            exit_code: process_result.exit_status,
393            signal: process_result.signal,
394            timed_out: false,
395            memory_peak,
396            cpu_time_us: process_result.exec_time_ms * 1000,
397            wall_time_ms,
398        };
399
400        let stream =
401            stream.ok_or_else(|| SandboxError::Io(std::io::Error::other("stream unavailable")))?;
402
403        Ok((sandbox_result, stream))
404    }
405
406    pub fn kill(&mut self) -> Result<()> {
407        if let Some(pid) = self.pid {
408            kill(pid, Signal::SIGKILL)
409                .map_err(|e| SandboxError::Syscall(format!("Failed to kill process: {}", e)))?;
410            self.pid = None;
411        }
412        Ok(())
413    }
414
415    /// Get resource usage
416    pub fn get_resource_usage(&self) -> Result<(u64, u64)> {
417        if let Some(ref cgroup) = self.cgroup {
418            let memory = cgroup.get_memory_usage()?;
419            let cpu = cgroup.get_cpu_usage()?;
420            Ok((memory, cpu))
421        } else {
422            Ok((0, 0))
423        }
424    }
425}
426
427impl Drop for Sandbox {
428    fn drop(&mut self) {
429        let _ = self.kill();
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::resources::cgroup::Cgroup;
437    use crate::test_support::serial_guard;
438    use crate::utils;
439    use std::env;
440    use std::time::Duration;
441    use tempfile::tempdir;
442
443    fn config_with_temp_root(id: &str) -> (tempfile::TempDir, SandboxConfig) {
444        let tmp = tempdir().unwrap();
445        let config = SandboxConfig {
446            id: id.to_string(),
447            root: tmp.path().join("root"),
448            namespace_config: NamespaceConfig::minimal(),
449            ..Default::default()
450        };
451        (tmp, config)
452    }
453
454    struct RootOverrideGuard;
455
456    impl RootOverrideGuard {
457        fn enable() -> Self {
458            utils::set_root_override(Some(true));
459            Self
460        }
461    }
462
463    impl Drop for RootOverrideGuard {
464        fn drop(&mut self) {
465            utils::set_root_override(None);
466        }
467    }
468
469    struct EnvVarGuard {
470        key: &'static str,
471        prev: Option<String>,
472    }
473
474    impl EnvVarGuard {
475        fn new(key: &'static str, value: &str) -> Self {
476            let prev = env::var(key).ok();
477            unsafe {
478                env::set_var(key, value);
479            }
480            Self { key, prev }
481        }
482    }
483
484    impl Drop for EnvVarGuard {
485        fn drop(&mut self) {
486            if let Some(ref value) = self.prev {
487                unsafe {
488                    env::set_var(self.key, value);
489                }
490            } else {
491                unsafe {
492                    env::remove_var(self.key);
493                }
494            }
495        }
496    }
497
498    #[test]
499    fn test_sandbox_config_default() {
500        let config = SandboxConfig::default();
501        assert_eq!(config.id, "default");
502        assert!(config.memory_limit.is_none());
503    }
504
505    #[test]
506    fn test_sandbox_config_validate() {
507        let config = SandboxConfig {
508            id: String::new(),
509            ..Default::default()
510        };
511
512        // Validation might fail due to root requirement, but we can test ID validation
513        // by checking the error message
514        if let Err(e) = config.validate() {
515            // Expected to fail, either due to root or empty ID
516            assert!(e.to_string().contains("ID") || e.to_string().contains("root"));
517        }
518    }
519
520    #[test]
521    fn test_sandbox_builder_new() {
522        let builder = SandboxBuilder::new("test");
523        assert_eq!(builder.config.id, "test");
524    }
525
526    #[test]
527    fn test_sandbox_builder_memory_limit() {
528        let builder = SandboxBuilder::new("test").memory_limit(100 * 1024 * 1024);
529        assert_eq!(builder.config.memory_limit, Some(100 * 1024 * 1024));
530    }
531
532    #[test]
533    fn test_sandbox_builder_memory_limit_str() -> Result<()> {
534        let builder = SandboxBuilder::new("test").memory_limit_str("100M")?;
535        assert_eq!(builder.config.memory_limit, Some(100 * 1024 * 1024));
536        Ok(())
537    }
538
539    #[test]
540    fn test_sandbox_builder_cpu_limit() {
541        let builder = SandboxBuilder::new("test").cpu_limit_percent(50);
542        assert!(builder.config.cpu_quota.is_some());
543    }
544
545    #[test]
546    fn test_sandbox_builder_cpu_limit_zero() {
547        let builder = SandboxBuilder::new("test").cpu_limit_percent(0);
548        assert!(builder.config.cpu_quota.is_none());
549    }
550
551    #[test]
552    fn test_sandbox_builder_cpu_limit_over_100() {
553        let builder = SandboxBuilder::new("test").cpu_limit_percent(150);
554        assert!(builder.config.cpu_quota.is_none());
555    }
556
557    #[test]
558    fn test_sandbox_builder_cpu_quota() {
559        let builder = SandboxBuilder::new("test").cpu_quota(50000, 100000);
560        assert_eq!(builder.config.cpu_quota, Some(50000));
561        assert_eq!(builder.config.cpu_period, Some(100000));
562    }
563
564    #[test]
565    fn test_sandbox_builder_max_pids() {
566        let builder = SandboxBuilder::new("test").max_pids(10);
567        assert_eq!(builder.config.max_pids, Some(10));
568    }
569
570    #[test]
571    fn test_sandbox_builder_seccomp_profile() {
572        let builder = SandboxBuilder::new("test").seccomp_profile(SeccompProfile::IoHeavy);
573        assert_eq!(builder.config.seccomp_profile, SeccompProfile::IoHeavy);
574    }
575
576    #[test]
577    fn test_sandbox_builder_root() {
578        let tmp = tempdir().unwrap();
579        let builder = SandboxBuilder::new("test").root(tmp.path());
580        assert_eq!(builder.config.root, tmp.path());
581    }
582
583    #[test]
584    fn test_sandbox_builder_timeout() {
585        let builder = SandboxBuilder::new("test").timeout(Duration::from_secs(30));
586        assert_eq!(builder.config.timeout, Some(Duration::from_secs(30)));
587    }
588
589    #[test]
590    fn test_sandbox_builder_namespaces() {
591        let ns_config = NamespaceConfig::minimal();
592        let builder = SandboxBuilder::new("test").namespaces(ns_config.clone());
593        assert_eq!(builder.config.namespace_config, ns_config);
594    }
595
596    #[test]
597    fn test_sandbox_result() {
598        let result = SandboxResult {
599            exit_code: 0,
600            signal: None,
601            timed_out: false,
602            memory_peak: 1024,
603            cpu_time_us: 5000,
604            wall_time_ms: 100,
605        };
606        assert_eq!(result.exit_code, 0);
607        assert!(!result.timed_out);
608    }
609
610    #[test]
611    fn sandbox_config_invariants_detect_empty_id() {
612        let config = SandboxConfig {
613            id: String::new(),
614            ..Default::default()
615        };
616        assert!(config.validate_invariants().is_err());
617    }
618
619    #[test]
620    fn sandbox_config_invariants_detect_disabled_namespaces() {
621        let config = SandboxConfig {
622            namespace_config: NamespaceConfig {
623                pid: false,
624                ipc: false,
625                net: false,
626                mount: false,
627                uts: false,
628                user: false,
629            },
630            ..Default::default()
631        };
632        assert!(config.validate_invariants().is_err());
633    }
634
635    #[test]
636    fn sandbox_provides_id_and_root() {
637        let (_tmp, config) = config_with_temp_root("sand-id");
638        let sandbox = Sandbox::new(config.clone()).unwrap();
639        assert_eq!(sandbox.id(), "sand-id");
640        assert!(sandbox.root().ends_with("root"));
641        assert!(!sandbox.is_running());
642    }
643
644    #[test]
645    fn sandbox_run_executes_command_without_root() {
646        let _guard = serial_guard();
647        let (_tmp, config) = config_with_temp_root("run-test");
648        let mut sandbox = Sandbox::new(config).unwrap();
649        let args: [&str; 1] = ["hello"];
650        let result = sandbox.run("/bin/echo", &args).unwrap();
651        assert_eq!(result.exit_code, 0);
652        assert!(!sandbox.is_running());
653    }
654
655    #[test]
656    fn sandbox_run_returns_error_if_already_running() {
657        let _guard = serial_guard();
658        let (_tmp, config) = config_with_temp_root("already-running");
659        let mut sandbox = Sandbox::new(config).unwrap();
660
661        // Set PID to simulate already running
662        sandbox.pid = Some(Pid::from_raw(1));
663
664        let args: [&str; 1] = ["test"];
665        let result = sandbox.run("/bin/echo", &args);
666
667        assert!(result.is_err());
668        assert!(result.unwrap_err().to_string().contains("already running"));
669    }
670
671    #[test]
672    fn test_sandbox_builder_build_creates_sandbox() {
673        let _guard = serial_guard();
674        let _root_guard = RootOverrideGuard::enable();
675        let tmp = tempdir().unwrap();
676        let sandbox = SandboxBuilder::new("build-test").root(tmp.path()).build();
677
678        assert!(sandbox.is_ok());
679    }
680
681    #[test]
682    fn test_sandbox_builder_build_validates_config() {
683        let _guard = serial_guard();
684        let tmp = tempdir().unwrap();
685        let result = SandboxBuilder::new("").root(tmp.path()).build();
686
687        assert!(result.is_err());
688    }
689
690    #[test]
691    fn sandbox_reports_resource_usage_from_cgroup() {
692        let (tmp, mut config) = config_with_temp_root("resource-test");
693        config.root = tmp.path().join("root");
694        let mut sandbox = Sandbox::new(config).unwrap();
695
696        let cg_path = tmp.path().join("cgroup");
697        std::fs::create_dir_all(&cg_path).unwrap();
698        std::fs::write(cg_path.join("memory.current"), "1234").unwrap();
699        std::fs::write(cg_path.join("cpu.stat"), "usage_usec 77\n").unwrap();
700
701        sandbox.cgroup = Some(Cgroup::for_testing(cg_path.clone()));
702        let (mem, cpu) = sandbox.get_resource_usage().unwrap();
703        assert_eq!(mem, 1234);
704        assert_eq!(cpu, 77);
705    }
706
707    #[test]
708    #[ignore]
709    fn sandbox_builder_builds_when_root_override() {
710        let _guard = serial_guard();
711        let _root_guard = RootOverrideGuard::enable();
712        let tmp = tempdir().unwrap();
713        let _env_guard = EnvVarGuard::new("SANDBOX_CGROUP_ROOT", tmp.path().to_str().unwrap());
714
715        let mut sandbox = SandboxBuilder::new("integration")
716            .memory_limit(1024)
717            .cpu_limit_percent(10)
718            .max_pids(4)
719            .seccomp_profile(SeccompProfile::Minimal)
720            .root(tmp.path())
721            .timeout(Duration::from_secs(1))
722            .namespaces(NamespaceConfig::minimal())
723            .build()
724            .unwrap();
725
726        let args: [&str; 0] = [];
727        let result = sandbox.run("/bin/true", &args).unwrap();
728        assert_eq!(result.exit_code, 0);
729    }
730
731    #[test]
732    fn sandbox_kill_handles_missing_pid() {
733        let (_tmp, config) = config_with_temp_root("kill-test");
734        let mut sandbox = Sandbox::new(config).unwrap();
735        sandbox.kill().unwrap();
736    }
737
738    #[test]
739    fn sandbox_kill_terminates_real_process() {
740        let (_tmp, config) = config_with_temp_root("kill-proc");
741        let mut sandbox = Sandbox::new(config).unwrap();
742        let mut child = std::process::Command::new("sleep")
743            .arg("1")
744            .spawn()
745            .unwrap();
746        sandbox.pid = Some(Pid::from_raw(child.id() as i32));
747        sandbox.kill().unwrap();
748        let _ = child.wait();
749    }
750
751    #[test]
752    fn sandbox_get_resource_usage_without_cgroup() {
753        let (_tmp, config) = config_with_temp_root("no-cgroup");
754        let sandbox = Sandbox::new(config).unwrap();
755        let (mem, cpu) = sandbox.get_resource_usage().unwrap();
756        assert_eq!(mem, 0);
757        assert_eq!(cpu, 0);
758    }
759
760    #[test]
761    #[ignore]
762    fn sandbox_run_with_stream_captures_output() {
763        let _guard = serial_guard();
764        let _root_guard = RootOverrideGuard::enable();
765        let (_tmp, config) = config_with_temp_root("stream-test");
766        let mut sandbox = Sandbox::new(config).unwrap();
767
768        let (result, stream) = sandbox
769            .run_with_stream("/bin/echo", &["hello world"])
770            .unwrap();
771
772        let chunks: Vec<_> = stream.into_iter().collect();
773
774        assert!(!chunks.is_empty());
775        assert_eq!(result.exit_code, 0);
776
777        let has_stdout = chunks
778            .iter()
779            .any(|chunk| matches!(chunk, crate::StreamChunk::Stdout(_)));
780        let has_exit = chunks
781            .iter()
782            .any(|chunk| matches!(chunk, crate::StreamChunk::Exit { .. }));
783
784        assert!(has_stdout, "Should have captured stdout");
785        assert!(has_exit, "Should have exit chunk");
786    }
787
788    #[test]
789    fn test_sandbox_result_killed_by_seccomp() {
790        let result = SandboxResult {
791            exit_code: 159,
792            signal: None,
793            timed_out: false,
794            memory_peak: 0,
795            cpu_time_us: 0,
796            wall_time_ms: 0,
797        };
798        assert!(result.killed_by_seccomp());
799    }
800
801    #[test]
802    fn test_sandbox_result_not_killed_by_seccomp() {
803        let result = SandboxResult {
804            exit_code: 0,
805            signal: None,
806            timed_out: false,
807            memory_peak: 0,
808            cpu_time_us: 0,
809            wall_time_ms: 0,
810        };
811        assert!(!result.killed_by_seccomp());
812    }
813
814    #[test]
815    fn test_sandbox_result_seccomp_error_message() {
816        let result = SandboxResult {
817            exit_code: 159,
818            signal: None,
819            timed_out: false,
820            memory_peak: 0,
821            cpu_time_us: 0,
822            wall_time_ms: 0,
823        };
824        let msg = result.seccomp_error();
825        assert!(msg.is_some());
826        assert!(msg.unwrap().contains("permissions"));
827    }
828
829    #[test]
830    fn test_sandbox_result_check_seccomp_error_when_killed() {
831        let result = SandboxResult {
832            exit_code: 159,
833            signal: None,
834            timed_out: false,
835            memory_peak: 0,
836            cpu_time_us: 0,
837            wall_time_ms: 0,
838        };
839        let check_result = result.check_seccomp_error();
840        assert!(check_result.is_err());
841        let err = check_result.unwrap_err();
842        assert!(err.to_string().contains("restrictive"));
843    }
844
845    #[test]
846    fn test_sandbox_result_check_seccomp_error_when_success() {
847        let result = SandboxResult {
848            exit_code: 0,
849            signal: None,
850            timed_out: false,
851            memory_peak: 0,
852            cpu_time_us: 0,
853            wall_time_ms: 0,
854        };
855        let check_result = result.check_seccomp_error();
856        assert!(check_result.is_ok());
857    }
858}