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
183/// Active sandbox
184pub struct Sandbox {
185    config: SandboxConfig,
186    pid: Option<Pid>,
187    cgroup: Option<Cgroup>,
188    start_time: Option<Instant>,
189}
190
191impl Sandbox {
192    /// Create new sandbox
193    fn new(config: SandboxConfig) -> Result<Self> {
194        // Create root directory
195        fs::create_dir_all(&config.root).map_err(|e| {
196            SandboxError::Io(std::io::Error::other(format!(
197                "Failed to create root directory: {}",
198                e
199            )))
200        })?;
201
202        Ok(Self {
203            config,
204            pid: None,
205            cgroup: None,
206            start_time: None,
207        })
208    }
209
210    /// Get sandbox ID
211    pub fn id(&self) -> &str {
212        &self.config.id
213    }
214
215    /// Get sandbox root
216    pub fn root(&self) -> &Path {
217        &self.config.root
218    }
219
220    /// Check if sandbox is running
221    pub fn is_running(&self) -> bool {
222        self.pid.is_some()
223    }
224
225    /// Run program in sandbox
226    pub fn run(&mut self, program: &str, args: &[&str]) -> Result<SandboxResult> {
227        if self.is_running() {
228            return Err(SandboxError::AlreadyRunning);
229        }
230
231        self.start_time = Some(Instant::now());
232
233        if utils::is_root() {
234            let cgroup_name = format!("sandbox-{}", self.config.id);
235            let cgroup = Cgroup::new(&cgroup_name, Pid::from_raw(std::process::id() as i32))?;
236
237            let cgroup_config = CgroupConfig {
238                memory_limit: self.config.memory_limit,
239                cpu_quota: self.config.cpu_quota,
240                cpu_period: self.config.cpu_period,
241                max_pids: self.config.max_pids,
242                cpu_weight: None,
243            };
244            cgroup.apply_config(&cgroup_config)?;
245
246            self.cgroup = Some(cgroup);
247        } else {
248            warn!(
249                "Skipping cgroup configuration for sandbox {} (not running as root)",
250                self.config.id
251            );
252        }
253
254        // Create process configuration with namespace and seccomp settings
255        let process_config = ProcessConfig {
256            program: program.to_string(),
257            args: args.iter().map(|s| s.to_string()).collect(),
258            env: Vec::new(), // Inherit parent environment
259            cwd: None,
260            chroot_dir: None,
261            uid: None,
262            gid: None,
263            seccomp: Some(crate::isolation::seccomp::SeccompFilter::from_profile(
264                self.config.seccomp_profile.clone(),
265            )),
266        };
267
268        // Execute with namespace isolation
269        if utils::is_root() {
270            // Real isolation with namespaces
271            let process_result =
272                ProcessExecutor::execute(process_config, self.config.namespace_config.clone())?;
273
274            self.pid = Some(process_result.pid);
275
276            let wall_time_ms = self.start_time.unwrap().elapsed().as_millis() as u64;
277
278            // Get peak memory from cgroup if available
279            let (memory_peak, _) = self.get_resource_usage().unwrap_or((0, 0));
280
281            Ok(SandboxResult {
282                exit_code: process_result.exit_status,
283                signal: process_result.signal,
284                timed_out: false,
285                memory_peak,
286                cpu_time_us: process_result.exec_time_ms * 1000, // Convert ms to us
287                wall_time_ms,
288            })
289        } else {
290            // Fallback: run without full namespace isolation (for testing)
291            warn!("Running without full isolation (not root). Use sudo for production sandboxes.");
292            let output = Command::new(program)
293                .args(args)
294                .output()
295                .map_err(SandboxError::Io)?;
296
297            let exit_code = output.status.code().unwrap_or(-1);
298            let wall_time_ms = self.start_time.unwrap().elapsed().as_millis() as u64;
299
300            Ok(SandboxResult {
301                exit_code,
302                signal: None,
303                timed_out: false,
304                memory_peak: 0,
305                cpu_time_us: 0,
306                wall_time_ms,
307            })
308        }
309    }
310
311    /// Run program with streaming output
312    pub fn run_with_stream(
313        &mut self,
314        program: &str,
315        args: &[&str],
316    ) -> Result<(SandboxResult, ProcessStream)> {
317        if self.is_running() {
318            return Err(SandboxError::AlreadyRunning);
319        }
320
321        self.start_time = Some(Instant::now());
322
323        let cgroup_name = format!("sandbox-{}", self.config.id);
324        let cgroup = Cgroup::new(&cgroup_name, Pid::from_raw(std::process::id() as i32))?;
325
326        let cgroup_config = CgroupConfig {
327            memory_limit: self.config.memory_limit,
328            cpu_quota: self.config.cpu_quota,
329            cpu_period: self.config.cpu_period,
330            max_pids: self.config.max_pids,
331            cpu_weight: None,
332        };
333        cgroup.apply_config(&cgroup_config)?;
334
335        self.cgroup = Some(cgroup);
336
337        let process_config = ProcessConfig {
338            program: program.to_string(),
339            args: args.iter().map(|s| s.to_string()).collect(),
340            env: Vec::new(),
341            cwd: None,
342            chroot_dir: None,
343            uid: None,
344            gid: None,
345            seccomp: Some(crate::isolation::seccomp::SeccompFilter::from_profile(
346                self.config.seccomp_profile.clone(),
347            )),
348        };
349
350        let (process_result, stream) = ProcessExecutor::execute_with_stream(
351            process_config,
352            self.config.namespace_config.clone(),
353            true,
354        )?;
355
356        self.pid = Some(process_result.pid);
357
358        let wall_time_ms = self.start_time.unwrap().elapsed().as_millis() as u64;
359        let (memory_peak, _) = self.get_resource_usage().unwrap_or((0, 0));
360
361        let sandbox_result = SandboxResult {
362            exit_code: process_result.exit_status,
363            signal: process_result.signal,
364            timed_out: false,
365            memory_peak,
366            cpu_time_us: process_result.exec_time_ms * 1000,
367            wall_time_ms,
368        };
369
370        let stream =
371            stream.ok_or_else(|| SandboxError::Io(std::io::Error::other("stream unavailable")))?;
372
373        Ok((sandbox_result, stream))
374    }
375
376    pub fn kill(&mut self) -> Result<()> {
377        if let Some(pid) = self.pid {
378            kill(pid, Signal::SIGKILL)
379                .map_err(|e| SandboxError::Syscall(format!("Failed to kill process: {}", e)))?;
380            self.pid = None;
381        }
382        Ok(())
383    }
384
385    /// Get resource usage
386    pub fn get_resource_usage(&self) -> Result<(u64, u64)> {
387        if let Some(ref cgroup) = self.cgroup {
388            let memory = cgroup.get_memory_usage()?;
389            let cpu = cgroup.get_cpu_usage()?;
390            Ok((memory, cpu))
391        } else {
392            Ok((0, 0))
393        }
394    }
395}
396
397impl Drop for Sandbox {
398    fn drop(&mut self) {
399        // Clean up on drop
400        let _ = self.kill();
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::resources::cgroup::Cgroup;
408    use crate::test_support::serial_guard;
409    use crate::utils;
410    use std::env;
411    use std::time::Duration;
412    use tempfile::tempdir;
413
414    fn config_with_temp_root(id: &str) -> (tempfile::TempDir, SandboxConfig) {
415        let tmp = tempdir().unwrap();
416        let config = SandboxConfig {
417            id: id.to_string(),
418            root: tmp.path().join("root"),
419            namespace_config: NamespaceConfig::minimal(),
420            ..Default::default()
421        };
422        (tmp, config)
423    }
424
425    struct RootOverrideGuard;
426
427    impl RootOverrideGuard {
428        fn enable() -> Self {
429            utils::set_root_override(Some(true));
430            Self
431        }
432    }
433
434    impl Drop for RootOverrideGuard {
435        fn drop(&mut self) {
436            utils::set_root_override(None);
437        }
438    }
439
440    struct EnvVarGuard {
441        key: &'static str,
442        prev: Option<String>,
443    }
444
445    impl EnvVarGuard {
446        fn new(key: &'static str, value: &str) -> Self {
447            let prev = env::var(key).ok();
448            unsafe {
449                env::set_var(key, value);
450            }
451            Self { key, prev }
452        }
453    }
454
455    impl Drop for EnvVarGuard {
456        fn drop(&mut self) {
457            if let Some(ref value) = self.prev {
458                unsafe {
459                    env::set_var(self.key, value);
460                }
461            } else {
462                unsafe {
463                    env::remove_var(self.key);
464                }
465            }
466        }
467    }
468
469    #[test]
470    fn test_sandbox_config_default() {
471        let config = SandboxConfig::default();
472        assert_eq!(config.id, "default");
473        assert!(config.memory_limit.is_none());
474    }
475
476    #[test]
477    fn test_sandbox_config_validate() {
478        let config = SandboxConfig {
479            id: String::new(),
480            ..Default::default()
481        };
482
483        // Validation might fail due to root requirement, but we can test ID validation
484        // by checking the error message
485        if let Err(e) = config.validate() {
486            // Expected to fail, either due to root or empty ID
487            assert!(e.to_string().contains("ID") || e.to_string().contains("root"));
488        }
489    }
490
491    #[test]
492    fn test_sandbox_builder_new() {
493        let builder = SandboxBuilder::new("test");
494        assert_eq!(builder.config.id, "test");
495    }
496
497    #[test]
498    fn test_sandbox_builder_memory_limit() {
499        let builder = SandboxBuilder::new("test").memory_limit(100 * 1024 * 1024);
500        assert_eq!(builder.config.memory_limit, Some(100 * 1024 * 1024));
501    }
502
503    #[test]
504    fn test_sandbox_builder_memory_limit_str() -> Result<()> {
505        let builder = SandboxBuilder::new("test").memory_limit_str("100M")?;
506        assert_eq!(builder.config.memory_limit, Some(100 * 1024 * 1024));
507        Ok(())
508    }
509
510    #[test]
511    fn test_sandbox_builder_cpu_limit() {
512        let builder = SandboxBuilder::new("test").cpu_limit_percent(50);
513        assert!(builder.config.cpu_quota.is_some());
514    }
515
516    #[test]
517    fn test_sandbox_builder_cpu_limit_zero() {
518        let builder = SandboxBuilder::new("test").cpu_limit_percent(0);
519        assert!(builder.config.cpu_quota.is_none());
520    }
521
522    #[test]
523    fn test_sandbox_builder_cpu_limit_over_100() {
524        let builder = SandboxBuilder::new("test").cpu_limit_percent(150);
525        assert!(builder.config.cpu_quota.is_none());
526    }
527
528    #[test]
529    fn test_sandbox_builder_cpu_quota() {
530        let builder = SandboxBuilder::new("test").cpu_quota(50000, 100000);
531        assert_eq!(builder.config.cpu_quota, Some(50000));
532        assert_eq!(builder.config.cpu_period, Some(100000));
533    }
534
535    #[test]
536    fn test_sandbox_builder_max_pids() {
537        let builder = SandboxBuilder::new("test").max_pids(10);
538        assert_eq!(builder.config.max_pids, Some(10));
539    }
540
541    #[test]
542    fn test_sandbox_builder_seccomp_profile() {
543        let builder = SandboxBuilder::new("test").seccomp_profile(SeccompProfile::IoHeavy);
544        assert_eq!(builder.config.seccomp_profile, SeccompProfile::IoHeavy);
545    }
546
547    #[test]
548    fn test_sandbox_builder_root() {
549        let tmp = tempdir().unwrap();
550        let builder = SandboxBuilder::new("test").root(tmp.path());
551        assert_eq!(builder.config.root, tmp.path());
552    }
553
554    #[test]
555    fn test_sandbox_builder_timeout() {
556        let builder = SandboxBuilder::new("test").timeout(Duration::from_secs(30));
557        assert_eq!(builder.config.timeout, Some(Duration::from_secs(30)));
558    }
559
560    #[test]
561    fn test_sandbox_builder_namespaces() {
562        let ns_config = NamespaceConfig::minimal();
563        let builder = SandboxBuilder::new("test").namespaces(ns_config.clone());
564        assert_eq!(builder.config.namespace_config, ns_config);
565    }
566
567    #[test]
568    fn test_sandbox_result() {
569        let result = SandboxResult {
570            exit_code: 0,
571            signal: None,
572            timed_out: false,
573            memory_peak: 1024,
574            cpu_time_us: 5000,
575            wall_time_ms: 100,
576        };
577        assert_eq!(result.exit_code, 0);
578        assert!(!result.timed_out);
579    }
580
581    #[test]
582    fn sandbox_config_invariants_detect_empty_id() {
583        let config = SandboxConfig {
584            id: String::new(),
585            ..Default::default()
586        };
587        assert!(config.validate_invariants().is_err());
588    }
589
590    #[test]
591    fn sandbox_config_invariants_detect_disabled_namespaces() {
592        let config = SandboxConfig {
593            namespace_config: NamespaceConfig {
594                pid: false,
595                ipc: false,
596                net: false,
597                mount: false,
598                uts: false,
599                user: false,
600            },
601            ..Default::default()
602        };
603        assert!(config.validate_invariants().is_err());
604    }
605
606    #[test]
607    fn sandbox_provides_id_and_root() {
608        let (_tmp, config) = config_with_temp_root("sand-id");
609        let sandbox = Sandbox::new(config.clone()).unwrap();
610        assert_eq!(sandbox.id(), "sand-id");
611        assert!(sandbox.root().ends_with("root"));
612        assert!(!sandbox.is_running());
613    }
614
615    #[test]
616    fn sandbox_run_executes_command_without_root() {
617        let _guard = serial_guard();
618        let (_tmp, config) = config_with_temp_root("run-test");
619        let mut sandbox = Sandbox::new(config).unwrap();
620        let args: [&str; 1] = ["hello"];
621        let result = sandbox.run("/bin/echo", &args).unwrap();
622        assert_eq!(result.exit_code, 0);
623        assert!(!sandbox.is_running());
624    }
625
626    #[test]
627    fn sandbox_run_returns_error_if_already_running() {
628        let _guard = serial_guard();
629        let (_tmp, config) = config_with_temp_root("already-running");
630        let mut sandbox = Sandbox::new(config).unwrap();
631
632        // Set PID to simulate already running
633        sandbox.pid = Some(Pid::from_raw(1));
634
635        let args: [&str; 1] = ["test"];
636        let result = sandbox.run("/bin/echo", &args);
637
638        assert!(result.is_err());
639        assert!(result.unwrap_err().to_string().contains("already running"));
640    }
641
642    #[test]
643    fn test_sandbox_builder_build_creates_sandbox() {
644        let _guard = serial_guard();
645        let _root_guard = RootOverrideGuard::enable();
646        let tmp = tempdir().unwrap();
647        let sandbox = SandboxBuilder::new("build-test").root(tmp.path()).build();
648
649        assert!(sandbox.is_ok());
650    }
651
652    #[test]
653    fn test_sandbox_builder_build_validates_config() {
654        let _guard = serial_guard();
655        let tmp = tempdir().unwrap();
656        let result = SandboxBuilder::new("").root(tmp.path()).build();
657
658        assert!(result.is_err());
659    }
660
661    #[test]
662    fn sandbox_reports_resource_usage_from_cgroup() {
663        let (tmp, mut config) = config_with_temp_root("resource-test");
664        config.root = tmp.path().join("root");
665        let mut sandbox = Sandbox::new(config).unwrap();
666
667        let cg_path = tmp.path().join("cgroup");
668        std::fs::create_dir_all(&cg_path).unwrap();
669        std::fs::write(cg_path.join("memory.current"), "1234").unwrap();
670        std::fs::write(cg_path.join("cpu.stat"), "usage_usec 77\n").unwrap();
671
672        sandbox.cgroup = Some(Cgroup::for_testing(cg_path.clone()));
673        let (mem, cpu) = sandbox.get_resource_usage().unwrap();
674        assert_eq!(mem, 1234);
675        assert_eq!(cpu, 77);
676    }
677
678    #[test]
679    #[ignore]
680    fn sandbox_builder_builds_when_root_override() {
681        let _guard = serial_guard();
682        let _root_guard = RootOverrideGuard::enable();
683        let tmp = tempdir().unwrap();
684        let _env_guard = EnvVarGuard::new("SANDBOX_CGROUP_ROOT", tmp.path().to_str().unwrap());
685
686        let mut sandbox = SandboxBuilder::new("integration")
687            .memory_limit(1024)
688            .cpu_limit_percent(10)
689            .max_pids(4)
690            .seccomp_profile(SeccompProfile::Minimal)
691            .root(tmp.path())
692            .timeout(Duration::from_secs(1))
693            .namespaces(NamespaceConfig::minimal())
694            .build()
695            .unwrap();
696
697        let args: [&str; 0] = [];
698        let result = sandbox.run("/bin/true", &args).unwrap();
699        assert_eq!(result.exit_code, 0);
700    }
701
702    #[test]
703    fn sandbox_kill_handles_missing_pid() {
704        let (_tmp, config) = config_with_temp_root("kill-test");
705        let mut sandbox = Sandbox::new(config).unwrap();
706        sandbox.kill().unwrap();
707    }
708
709    #[test]
710    fn sandbox_kill_terminates_real_process() {
711        let (_tmp, config) = config_with_temp_root("kill-proc");
712        let mut sandbox = Sandbox::new(config).unwrap();
713        let mut child = std::process::Command::new("sleep")
714            .arg("1")
715            .spawn()
716            .unwrap();
717        sandbox.pid = Some(Pid::from_raw(child.id() as i32));
718        sandbox.kill().unwrap();
719        let _ = child.wait();
720    }
721
722    #[test]
723    fn sandbox_get_resource_usage_without_cgroup() {
724        let (_tmp, config) = config_with_temp_root("no-cgroup");
725        let sandbox = Sandbox::new(config).unwrap();
726        let (mem, cpu) = sandbox.get_resource_usage().unwrap();
727        assert_eq!(mem, 0);
728        assert_eq!(cpu, 0);
729    }
730
731    #[test]
732    #[ignore]
733    fn sandbox_run_with_stream_captures_output() {
734        let _guard = serial_guard();
735        let _root_guard = RootOverrideGuard::enable();
736        let (_tmp, config) = config_with_temp_root("stream-test");
737        let mut sandbox = Sandbox::new(config).unwrap();
738
739        let (result, stream) = sandbox
740            .run_with_stream("/bin/echo", &["hello world"])
741            .unwrap();
742
743        let chunks: Vec<_> = stream.into_iter().collect();
744
745        assert!(!chunks.is_empty());
746        assert_eq!(result.exit_code, 0);
747
748        let has_stdout = chunks
749            .iter()
750            .any(|chunk| matches!(chunk, crate::StreamChunk::Stdout(_)));
751        let has_exit = chunks
752            .iter()
753            .any(|chunk| matches!(chunk, crate::StreamChunk::Exit { .. }));
754
755        assert!(has_stdout, "Should have captured stdout");
756        assert!(has_exit, "Should have exit chunk");
757    }
758}