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        // Create process configuration with namespace and seccomp settings
285        let process_config = ProcessConfig {
286            program: program.to_string(),
287            args: args.iter().map(|s| s.to_string()).collect(),
288            env: Vec::new(), // Inherit parent environment
289            cwd: None,
290            chroot_dir: None,
291            uid: None,
292            gid: None,
293            seccomp: Some(crate::isolation::seccomp::SeccompFilter::from_profile(
294                self.config.seccomp_profile.clone(),
295            )),
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, // Convert ms to us
317                wall_time_ms,
318            })
319        } else {
320            // Fallback: run without full namespace isolation (for testing)
321            warn!("Running without full isolation (not root). Use sudo for production sandboxes.");
322            let output = Command::new(program)
323                .args(args)
324                .output()
325                .map_err(SandboxError::Io)?;
326
327            let exit_code = output.status.code().unwrap_or(-1);
328            let wall_time_ms = self.start_time.unwrap().elapsed().as_millis() as u64;
329
330            Ok(SandboxResult {
331                exit_code,
332                signal: None,
333                timed_out: false,
334                memory_peak: 0,
335                cpu_time_us: 0,
336                wall_time_ms,
337            })
338        }
339    }
340
341    /// Run program with streaming output
342    pub fn run_with_stream(
343        &mut self,
344        program: &str,
345        args: &[&str],
346    ) -> Result<(SandboxResult, ProcessStream)> {
347        if self.is_running() {
348            return Err(SandboxError::AlreadyRunning);
349        }
350
351        self.start_time = Some(Instant::now());
352
353        let cgroup_name = format!("sandbox-{}", self.config.id);
354        let cgroup = Cgroup::new(&cgroup_name, Pid::from_raw(std::process::id() as i32))?;
355
356        let cgroup_config = CgroupConfig {
357            memory_limit: self.config.memory_limit,
358            cpu_quota: self.config.cpu_quota,
359            cpu_period: self.config.cpu_period,
360            max_pids: self.config.max_pids,
361            cpu_weight: None,
362        };
363        cgroup.apply_config(&cgroup_config)?;
364
365        self.cgroup = Some(cgroup);
366
367        let process_config = ProcessConfig {
368            program: program.to_string(),
369            args: args.iter().map(|s| s.to_string()).collect(),
370            env: Vec::new(),
371            cwd: None,
372            chroot_dir: None,
373            uid: None,
374            gid: None,
375            seccomp: Some(crate::isolation::seccomp::SeccompFilter::from_profile(
376                self.config.seccomp_profile.clone(),
377            )),
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        // Clean up on drop
430        let _ = self.kill();
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::resources::cgroup::Cgroup;
438    use crate::test_support::serial_guard;
439    use crate::utils;
440    use std::env;
441    use std::time::Duration;
442    use tempfile::tempdir;
443
444    fn config_with_temp_root(id: &str) -> (tempfile::TempDir, SandboxConfig) {
445        let tmp = tempdir().unwrap();
446        let config = SandboxConfig {
447            id: id.to_string(),
448            root: tmp.path().join("root"),
449            namespace_config: NamespaceConfig::minimal(),
450            ..Default::default()
451        };
452        (tmp, config)
453    }
454
455    struct RootOverrideGuard;
456
457    impl RootOverrideGuard {
458        fn enable() -> Self {
459            utils::set_root_override(Some(true));
460            Self
461        }
462    }
463
464    impl Drop for RootOverrideGuard {
465        fn drop(&mut self) {
466            utils::set_root_override(None);
467        }
468    }
469
470    struct EnvVarGuard {
471        key: &'static str,
472        prev: Option<String>,
473    }
474
475    impl EnvVarGuard {
476        fn new(key: &'static str, value: &str) -> Self {
477            let prev = env::var(key).ok();
478            unsafe {
479                env::set_var(key, value);
480            }
481            Self { key, prev }
482        }
483    }
484
485    impl Drop for EnvVarGuard {
486        fn drop(&mut self) {
487            if let Some(ref value) = self.prev {
488                unsafe {
489                    env::set_var(self.key, value);
490                }
491            } else {
492                unsafe {
493                    env::remove_var(self.key);
494                }
495            }
496        }
497    }
498
499    #[test]
500    fn test_sandbox_config_default() {
501        let config = SandboxConfig::default();
502        assert_eq!(config.id, "default");
503        assert!(config.memory_limit.is_none());
504    }
505
506    #[test]
507    fn test_sandbox_config_validate() {
508        let config = SandboxConfig {
509            id: String::new(),
510            ..Default::default()
511        };
512
513        // Validation might fail due to root requirement, but we can test ID validation
514        // by checking the error message
515        if let Err(e) = config.validate() {
516            // Expected to fail, either due to root or empty ID
517            assert!(e.to_string().contains("ID") || e.to_string().contains("root"));
518        }
519    }
520
521    #[test]
522    fn test_sandbox_builder_new() {
523        let builder = SandboxBuilder::new("test");
524        assert_eq!(builder.config.id, "test");
525    }
526
527    #[test]
528    fn test_sandbox_builder_memory_limit() {
529        let builder = SandboxBuilder::new("test").memory_limit(100 * 1024 * 1024);
530        assert_eq!(builder.config.memory_limit, Some(100 * 1024 * 1024));
531    }
532
533    #[test]
534    fn test_sandbox_builder_memory_limit_str() -> Result<()> {
535        let builder = SandboxBuilder::new("test").memory_limit_str("100M")?;
536        assert_eq!(builder.config.memory_limit, Some(100 * 1024 * 1024));
537        Ok(())
538    }
539
540    #[test]
541    fn test_sandbox_builder_cpu_limit() {
542        let builder = SandboxBuilder::new("test").cpu_limit_percent(50);
543        assert!(builder.config.cpu_quota.is_some());
544    }
545
546    #[test]
547    fn test_sandbox_builder_cpu_limit_zero() {
548        let builder = SandboxBuilder::new("test").cpu_limit_percent(0);
549        assert!(builder.config.cpu_quota.is_none());
550    }
551
552    #[test]
553    fn test_sandbox_builder_cpu_limit_over_100() {
554        let builder = SandboxBuilder::new("test").cpu_limit_percent(150);
555        assert!(builder.config.cpu_quota.is_none());
556    }
557
558    #[test]
559    fn test_sandbox_builder_cpu_quota() {
560        let builder = SandboxBuilder::new("test").cpu_quota(50000, 100000);
561        assert_eq!(builder.config.cpu_quota, Some(50000));
562        assert_eq!(builder.config.cpu_period, Some(100000));
563    }
564
565    #[test]
566    fn test_sandbox_builder_max_pids() {
567        let builder = SandboxBuilder::new("test").max_pids(10);
568        assert_eq!(builder.config.max_pids, Some(10));
569    }
570
571    #[test]
572    fn test_sandbox_builder_seccomp_profile() {
573        let builder = SandboxBuilder::new("test").seccomp_profile(SeccompProfile::IoHeavy);
574        assert_eq!(builder.config.seccomp_profile, SeccompProfile::IoHeavy);
575    }
576
577    #[test]
578    fn test_sandbox_builder_root() {
579        let tmp = tempdir().unwrap();
580        let builder = SandboxBuilder::new("test").root(tmp.path());
581        assert_eq!(builder.config.root, tmp.path());
582    }
583
584    #[test]
585    fn test_sandbox_builder_timeout() {
586        let builder = SandboxBuilder::new("test").timeout(Duration::from_secs(30));
587        assert_eq!(builder.config.timeout, Some(Duration::from_secs(30)));
588    }
589
590    #[test]
591    fn test_sandbox_builder_namespaces() {
592        let ns_config = NamespaceConfig::minimal();
593        let builder = SandboxBuilder::new("test").namespaces(ns_config.clone());
594        assert_eq!(builder.config.namespace_config, ns_config);
595    }
596
597    #[test]
598    fn test_sandbox_result() {
599        let result = SandboxResult {
600            exit_code: 0,
601            signal: None,
602            timed_out: false,
603            memory_peak: 1024,
604            cpu_time_us: 5000,
605            wall_time_ms: 100,
606        };
607        assert_eq!(result.exit_code, 0);
608        assert!(!result.timed_out);
609    }
610
611    #[test]
612    fn sandbox_config_invariants_detect_empty_id() {
613        let config = SandboxConfig {
614            id: String::new(),
615            ..Default::default()
616        };
617        assert!(config.validate_invariants().is_err());
618    }
619
620    #[test]
621    fn sandbox_config_invariants_detect_disabled_namespaces() {
622        let config = SandboxConfig {
623            namespace_config: NamespaceConfig {
624                pid: false,
625                ipc: false,
626                net: false,
627                mount: false,
628                uts: false,
629                user: false,
630            },
631            ..Default::default()
632        };
633        assert!(config.validate_invariants().is_err());
634    }
635
636    #[test]
637    fn sandbox_provides_id_and_root() {
638        let (_tmp, config) = config_with_temp_root("sand-id");
639        let sandbox = Sandbox::new(config.clone()).unwrap();
640        assert_eq!(sandbox.id(), "sand-id");
641        assert!(sandbox.root().ends_with("root"));
642        assert!(!sandbox.is_running());
643    }
644
645    #[test]
646    fn sandbox_run_executes_command_without_root() {
647        let _guard = serial_guard();
648        let (_tmp, config) = config_with_temp_root("run-test");
649        let mut sandbox = Sandbox::new(config).unwrap();
650        let args: [&str; 1] = ["hello"];
651        let result = sandbox.run("/bin/echo", &args).unwrap();
652        assert_eq!(result.exit_code, 0);
653        assert!(!sandbox.is_running());
654    }
655
656    #[test]
657    fn sandbox_run_returns_error_if_already_running() {
658        let _guard = serial_guard();
659        let (_tmp, config) = config_with_temp_root("already-running");
660        let mut sandbox = Sandbox::new(config).unwrap();
661
662        // Set PID to simulate already running
663        sandbox.pid = Some(Pid::from_raw(1));
664
665        let args: [&str; 1] = ["test"];
666        let result = sandbox.run("/bin/echo", &args);
667
668        assert!(result.is_err());
669        assert!(result.unwrap_err().to_string().contains("already running"));
670    }
671
672    #[test]
673    fn test_sandbox_builder_build_creates_sandbox() {
674        let _guard = serial_guard();
675        let _root_guard = RootOverrideGuard::enable();
676        let tmp = tempdir().unwrap();
677        let sandbox = SandboxBuilder::new("build-test").root(tmp.path()).build();
678
679        assert!(sandbox.is_ok());
680    }
681
682    #[test]
683    fn test_sandbox_builder_build_validates_config() {
684        let _guard = serial_guard();
685        let tmp = tempdir().unwrap();
686        let result = SandboxBuilder::new("").root(tmp.path()).build();
687
688        assert!(result.is_err());
689    }
690
691    #[test]
692    fn sandbox_reports_resource_usage_from_cgroup() {
693        let (tmp, mut config) = config_with_temp_root("resource-test");
694        config.root = tmp.path().join("root");
695        let mut sandbox = Sandbox::new(config).unwrap();
696
697        let cg_path = tmp.path().join("cgroup");
698        std::fs::create_dir_all(&cg_path).unwrap();
699        std::fs::write(cg_path.join("memory.current"), "1234").unwrap();
700        std::fs::write(cg_path.join("cpu.stat"), "usage_usec 77\n").unwrap();
701
702        sandbox.cgroup = Some(Cgroup::for_testing(cg_path.clone()));
703        let (mem, cpu) = sandbox.get_resource_usage().unwrap();
704        assert_eq!(mem, 1234);
705        assert_eq!(cpu, 77);
706    }
707
708    #[test]
709    #[ignore]
710    fn sandbox_builder_builds_when_root_override() {
711        let _guard = serial_guard();
712        let _root_guard = RootOverrideGuard::enable();
713        let tmp = tempdir().unwrap();
714        let _env_guard = EnvVarGuard::new("SANDBOX_CGROUP_ROOT", tmp.path().to_str().unwrap());
715
716        let mut sandbox = SandboxBuilder::new("integration")
717            .memory_limit(1024)
718            .cpu_limit_percent(10)
719            .max_pids(4)
720            .seccomp_profile(SeccompProfile::Minimal)
721            .root(tmp.path())
722            .timeout(Duration::from_secs(1))
723            .namespaces(NamespaceConfig::minimal())
724            .build()
725            .unwrap();
726
727        let args: [&str; 0] = [];
728        let result = sandbox.run("/bin/true", &args).unwrap();
729        assert_eq!(result.exit_code, 0);
730    }
731
732    #[test]
733    fn sandbox_kill_handles_missing_pid() {
734        let (_tmp, config) = config_with_temp_root("kill-test");
735        let mut sandbox = Sandbox::new(config).unwrap();
736        sandbox.kill().unwrap();
737    }
738
739    #[test]
740    fn sandbox_kill_terminates_real_process() {
741        let (_tmp, config) = config_with_temp_root("kill-proc");
742        let mut sandbox = Sandbox::new(config).unwrap();
743        let mut child = std::process::Command::new("sleep")
744            .arg("1")
745            .spawn()
746            .unwrap();
747        sandbox.pid = Some(Pid::from_raw(child.id() as i32));
748        sandbox.kill().unwrap();
749        let _ = child.wait();
750    }
751
752    #[test]
753    fn sandbox_get_resource_usage_without_cgroup() {
754        let (_tmp, config) = config_with_temp_root("no-cgroup");
755        let sandbox = Sandbox::new(config).unwrap();
756        let (mem, cpu) = sandbox.get_resource_usage().unwrap();
757        assert_eq!(mem, 0);
758        assert_eq!(cpu, 0);
759    }
760
761    #[test]
762    #[ignore]
763    fn sandbox_run_with_stream_captures_output() {
764        let _guard = serial_guard();
765        let _root_guard = RootOverrideGuard::enable();
766        let (_tmp, config) = config_with_temp_root("stream-test");
767        let mut sandbox = Sandbox::new(config).unwrap();
768
769        let (result, stream) = sandbox
770            .run_with_stream("/bin/echo", &["hello world"])
771            .unwrap();
772
773        let chunks: Vec<_> = stream.into_iter().collect();
774
775        assert!(!chunks.is_empty());
776        assert_eq!(result.exit_code, 0);
777
778        let has_stdout = chunks
779            .iter()
780            .any(|chunk| matches!(chunk, crate::StreamChunk::Stdout(_)));
781        let has_exit = chunks
782            .iter()
783            .any(|chunk| matches!(chunk, crate::StreamChunk::Exit { .. }));
784
785        assert!(has_stdout, "Should have captured stdout");
786        assert!(has_exit, "Should have exit chunk");
787    }
788
789    #[test]
790    fn test_sandbox_result_killed_by_seccomp() {
791        let result = SandboxResult {
792            exit_code: 159,
793            signal: None,
794            timed_out: false,
795            memory_peak: 0,
796            cpu_time_us: 0,
797            wall_time_ms: 0,
798        };
799        assert!(result.killed_by_seccomp());
800    }
801
802    #[test]
803    fn test_sandbox_result_not_killed_by_seccomp() {
804        let result = SandboxResult {
805            exit_code: 0,
806            signal: None,
807            timed_out: false,
808            memory_peak: 0,
809            cpu_time_us: 0,
810            wall_time_ms: 0,
811        };
812        assert!(!result.killed_by_seccomp());
813    }
814
815    #[test]
816    fn test_sandbox_result_seccomp_error_message() {
817        let result = SandboxResult {
818            exit_code: 159,
819            signal: None,
820            timed_out: false,
821            memory_peak: 0,
822            cpu_time_us: 0,
823            wall_time_ms: 0,
824        };
825        let msg = result.seccomp_error();
826        assert!(msg.is_some());
827        assert!(msg.unwrap().contains("permissions"));
828    }
829
830    #[test]
831    fn test_sandbox_result_check_seccomp_error_when_killed() {
832        let result = SandboxResult {
833            exit_code: 159,
834            signal: None,
835            timed_out: false,
836            memory_peak: 0,
837            cpu_time_us: 0,
838            wall_time_ms: 0,
839        };
840        let check_result = result.check_seccomp_error();
841        assert!(check_result.is_err());
842        let err = check_result.unwrap_err();
843        assert!(err.to_string().contains("restrictive"));
844    }
845
846    #[test]
847    fn test_sandbox_result_check_seccomp_error_when_success() {
848        let result = SandboxResult {
849            exit_code: 0,
850            signal: None,
851            timed_out: false,
852            memory_peak: 0,
853            cpu_time_us: 0,
854            wall_time_ms: 0,
855        };
856        let check_result = result.check_seccomp_error();
857        assert!(check_result.is_ok());
858    }
859}