Skip to main content

sandbox_rs/
controller.rs

1//! Main sandbox controller with privilege mode support
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, Instant};
6
7use nix::sys::signal::{Signal, kill};
8use nix::unistd::Pid;
9
10use sandbox_cgroup::cgroup::{Cgroup, CgroupConfig};
11use sandbox_cgroup::rlimit::RlimitConfig;
12use sandbox_core::capabilities::SystemCapabilities;
13use sandbox_core::privilege::{PrivilegeMode, ResolvedMode};
14use sandbox_core::{Result, SandboxError};
15use sandbox_namespace::NamespaceConfig;
16use sandbox_seccomp::{SeccompFilter, SeccompProfile};
17
18use crate::execution::ProcessStream;
19use crate::execution::process::{ProcessConfig, ProcessExecutor};
20
21/// Sandbox configuration
22#[derive(Debug, Clone)]
23pub struct SandboxConfig {
24    /// Root directory for sandbox
25    pub root: PathBuf,
26    /// Memory limit in bytes
27    pub memory_limit: Option<u64>,
28    /// CPU quota (microseconds)
29    pub cpu_quota: Option<u64>,
30    /// CPU period (microseconds)
31    pub cpu_period: Option<u64>,
32    /// Maximum PIDs
33    pub max_pids: Option<u32>,
34    /// Seccomp profile
35    pub seccomp_profile: SeccompProfile,
36    /// Namespace configuration
37    pub namespace_config: NamespaceConfig,
38    /// Timeout
39    pub timeout: Option<Duration>,
40    /// Unique sandbox ID
41    pub id: String,
42    /// Privilege mode
43    pub privilege_mode: PrivilegeMode,
44}
45
46impl Default for SandboxConfig {
47    fn default() -> Self {
48        Self {
49            root: PathBuf::from("/tmp/sandbox"),
50            memory_limit: None,
51            cpu_quota: None,
52            cpu_period: None,
53            max_pids: None,
54            seccomp_profile: SeccompProfile::Minimal,
55            namespace_config: NamespaceConfig::default(),
56            timeout: None,
57            id: "default".to_string(),
58            privilege_mode: PrivilegeMode::Auto,
59        }
60    }
61}
62
63impl SandboxConfig {
64    /// Validate configuration (no longer requires root!)
65    pub fn validate(&self) -> Result<()> {
66        self.validate_invariants()?;
67
68        let caps = SystemCapabilities::detect();
69        let mode = self.privilege_mode.resolve(&caps);
70
71        match mode {
72            ResolvedMode::Privileged => {
73                if !caps.has_root {
74                    return Err(SandboxError::PermissionDenied(
75                        "Privileged mode requires root privileges".to_string(),
76                    ));
77                }
78            }
79            ResolvedMode::Unprivileged => {
80                if !caps.has_seccomp {
81                    return Err(SandboxError::FeatureNotAvailable(
82                        "Seccomp is required for unprivileged sandboxing".to_string(),
83                    ));
84                }
85            }
86        }
87
88        Ok(())
89    }
90
91    fn validate_invariants(&self) -> Result<()> {
92        if self.id.is_empty() {
93            return Err(SandboxError::InvalidConfig(
94                "Sandbox ID cannot be empty".to_string(),
95            ));
96        }
97
98        if self.namespace_config.enabled_count() == 0 {
99            return Err(SandboxError::InvalidConfig(
100                "At least one namespace must be enabled".to_string(),
101            ));
102        }
103
104        Ok(())
105    }
106}
107
108/// Builder pattern for sandbox creation
109pub struct SandboxBuilder {
110    config: SandboxConfig,
111}
112
113impl SandboxBuilder {
114    /// Create new builder
115    pub fn new(id: &str) -> Self {
116        Self {
117            config: SandboxConfig {
118                id: id.to_string(),
119                ..Default::default()
120            },
121        }
122    }
123
124    /// Set memory limit
125    pub fn memory_limit(mut self, bytes: u64) -> Self {
126        self.config.memory_limit = Some(bytes);
127        self
128    }
129
130    /// Set memory limit from string (e.g., "100M")
131    pub fn memory_limit_str(self, s: &str) -> Result<Self> {
132        let bytes = sandbox_core::util::parse_memory_size(s)?;
133        Ok(self.memory_limit(bytes))
134    }
135
136    /// Set CPU quota
137    pub fn cpu_quota(mut self, quota: u64, period: u64) -> Self {
138        self.config.cpu_quota = Some(quota);
139        self.config.cpu_period = Some(period);
140        self
141    }
142
143    /// Set CPU limit by percentage (0-100)
144    pub fn cpu_limit_percent(self, percent: u32) -> Self {
145        if percent == 0 || percent > 100 {
146            return self;
147        }
148        let quota = (percent as u64) * 1000;
149        let period = 100000;
150        self.cpu_quota(quota, period)
151    }
152
153    /// Set maximum PIDs
154    pub fn max_pids(mut self, max: u32) -> Self {
155        self.config.max_pids = Some(max);
156        self
157    }
158
159    /// Set seccomp profile
160    pub fn seccomp_profile(mut self, profile: SeccompProfile) -> Self {
161        self.config.seccomp_profile = profile;
162        self
163    }
164
165    /// Set root directory
166    pub fn root(mut self, path: impl AsRef<Path>) -> Self {
167        self.config.root = path.as_ref().to_path_buf();
168        self
169    }
170
171    /// Set timeout
172    pub fn timeout(mut self, duration: Duration) -> Self {
173        self.config.timeout = Some(duration);
174        self
175    }
176
177    /// Set namespace configuration
178    pub fn namespaces(mut self, config: NamespaceConfig) -> Self {
179        self.config.namespace_config = config;
180        self
181    }
182
183    /// Set privilege mode
184    pub fn privilege_mode(mut self, mode: PrivilegeMode) -> Self {
185        self.config.privilege_mode = mode;
186        self
187    }
188
189    /// Build sandbox
190    pub fn build(self) -> Result<Sandbox> {
191        self.config.validate()?;
192        Sandbox::new(self.config)
193    }
194}
195
196/// Sandbox execution result
197#[derive(Debug, Clone)]
198pub struct SandboxResult {
199    /// Exit code
200    pub exit_code: i32,
201    /// Signal that killed process (if any)
202    pub signal: Option<i32>,
203    /// Whether timeout occurred
204    pub timed_out: bool,
205    /// Peak memory usage in bytes.
206    /// Requires privileged mode (cgroups v2). Returns `0` in unprivileged mode.
207    pub memory_peak: u64,
208    /// CPU time in microseconds.
209    /// Requires privileged mode (cgroups v2). Returns `0` in unprivileged mode.
210    pub cpu_time_us: u64,
211    /// Wall clock time in milliseconds
212    pub wall_time_ms: u64,
213}
214
215impl SandboxResult {
216    /// Check if process was killed by seccomp (SIGSYS - signal 31)
217    pub fn killed_by_seccomp(&self) -> bool {
218        self.exit_code == 159
219    }
220
221    /// Get human-readable error message if process failed due to seccomp
222    pub fn seccomp_error(&self) -> Option<&'static str> {
223        if self.killed_by_seccomp() {
224            Some("The action requires more permissions than were granted.")
225        } else {
226            None
227        }
228    }
229
230    /// Convert to Result, returning error if process was killed by seccomp
231    pub fn check_seccomp_error(&self) -> Result<&SandboxResult> {
232        if self.killed_by_seccomp() {
233            Err(SandboxError::PermissionDenied(
234                "The seccomp profile is too restrictive for this operation. \
235                 Try using a less restrictive profile (e.g., SeccompProfile::Compute or SeccompProfile::Unrestricted)"
236                    .to_string(),
237            ))
238        } else {
239            Ok(self)
240        }
241    }
242}
243
244/// Active sandbox
245pub struct Sandbox {
246    config: SandboxConfig,
247    resolved_mode: ResolvedMode,
248    pid: Option<Pid>,
249    cgroup: Option<Cgroup>,
250    start_time: Option<Instant>,
251}
252
253impl Sandbox {
254    /// Create new sandbox
255    fn new(config: SandboxConfig) -> Result<Self> {
256        let caps = SystemCapabilities::detect();
257        let resolved_mode = config.privilege_mode.resolve(&caps);
258
259        // Adjust namespace config based on resolved mode
260        let mut config = config;
261        if resolved_mode.is_unprivileged() && !config.namespace_config.user {
262            // Force user namespace on for unprivileged mode
263            config.namespace_config.user = true;
264        }
265
266        // Create root directory
267        fs::create_dir_all(&config.root).map_err(|e| {
268            SandboxError::Io(std::io::Error::other(format!(
269                "Failed to create root directory: {}",
270                e
271            )))
272        })?;
273
274        Ok(Self {
275            config,
276            resolved_mode,
277            pid: None,
278            cgroup: None,
279            start_time: None,
280        })
281    }
282
283    /// Get sandbox ID
284    pub fn id(&self) -> &str {
285        &self.config.id
286    }
287
288    /// Get sandbox root
289    pub fn root(&self) -> &Path {
290        &self.config.root
291    }
292
293    /// Check if sandbox is running
294    pub fn is_running(&self) -> bool {
295        self.pid.is_some()
296    }
297
298    /// Get the resolved privilege mode
299    pub fn privilege_mode(&self) -> ResolvedMode {
300        self.resolved_mode
301    }
302
303    /// Build a ProcessConfig from the sandbox configuration (shared setup)
304    fn build_process_config(&self) -> ProcessConfig {
305        ProcessConfig {
306            program: String::new(), // filled in by caller
307            args: Vec::new(),       // filled in by caller
308            env: Vec::new(),
309            cwd: None,
310            chroot_dir: None,
311            uid: None,
312            gid: None,
313            seccomp: Some(SeccompFilter::from_profile(
314                self.config.seccomp_profile.clone(),
315            )),
316            rlimits: if self.resolved_mode.is_unprivileged() {
317                Some(self.build_rlimit_config())
318            } else {
319                None
320            },
321            inherit_env: true,
322            use_user_namespace: self.config.namespace_config.user,
323        }
324    }
325
326    /// Build rlimit config from sandbox config (unprivileged fallback)
327    ///
328    /// Note: cpu_quota is a rate limit (microseconds per period) for cgroups,
329    /// which has no rlimit equivalent. RLIMIT_CPU is a total CPU-seconds cap,
330    /// so we derive it from the timeout instead.
331    fn build_rlimit_config(&self) -> RlimitConfig {
332        RlimitConfig {
333            max_memory: self.config.memory_limit,
334            max_cpu_seconds: self.config.timeout.map(|t| t.as_secs()),
335            max_processes: self.config.max_pids.map(|p| p as u64),
336            ..Default::default()
337        }
338    }
339
340    /// Setup cgroup for privileged mode, returns memory/cpu usage reader
341    fn setup_cgroup(&mut self) -> Result<()> {
342        if self.resolved_mode.is_unprivileged() {
343            return Ok(());
344        }
345
346        let cgroup_name = format!("sandbox-{}", self.config.id);
347        let cgroup = Cgroup::new(&cgroup_name, Pid::from_raw(std::process::id() as i32))?;
348
349        let cgroup_config = CgroupConfig {
350            memory_limit: self.config.memory_limit,
351            cpu_quota: self.config.cpu_quota,
352            cpu_period: self.config.cpu_period,
353            max_pids: self.config.max_pids,
354            cpu_weight: None,
355        };
356        cgroup.apply_config(&cgroup_config)?;
357        self.cgroup = Some(cgroup);
358        Ok(())
359    }
360
361    /// Run program in sandbox
362    pub fn run(&mut self, program: &str, args: &[&str]) -> Result<SandboxResult> {
363        if self.is_running() {
364            return Err(SandboxError::AlreadyRunning);
365        }
366
367        self.start_time = Some(Instant::now());
368        self.setup_cgroup()?;
369
370        let mut process_config = self.build_process_config();
371        process_config.program = program.to_string();
372        process_config.args = args.iter().map(|s| s.to_string()).collect();
373
374        let process_result =
375            ProcessExecutor::execute(process_config, self.config.namespace_config.clone())?;
376
377        self.pid = Some(process_result.pid);
378
379        let wall_time_ms = self.start_time.unwrap().elapsed().as_millis() as u64;
380        let (memory_peak, cpu_time_us) = self.get_resource_usage().unwrap_or((0, 0));
381
382        Ok(SandboxResult {
383            exit_code: process_result.exit_status,
384            signal: process_result.signal,
385            timed_out: false,
386            memory_peak,
387            cpu_time_us,
388            wall_time_ms,
389        })
390    }
391
392    /// Run program with streaming output.
393    /// Returns (ProcessHandle, ProcessStream) - the handle provides the actual exit status.
394    pub fn run_with_stream(
395        &mut self,
396        program: &str,
397        args: &[&str],
398    ) -> Result<(SandboxResult, ProcessStream)> {
399        if self.is_running() {
400            return Err(SandboxError::AlreadyRunning);
401        }
402
403        self.start_time = Some(Instant::now());
404        self.setup_cgroup()?;
405
406        let mut process_config = self.build_process_config();
407        process_config.program = program.to_string();
408        process_config.args = args.iter().map(|s| s.to_string()).collect();
409
410        let (process_result, stream) = ProcessExecutor::execute_with_stream(
411            process_config,
412            self.config.namespace_config.clone(),
413            true,
414        )?;
415
416        self.pid = Some(process_result.pid);
417
418        let wall_time_ms = self.start_time.unwrap().elapsed().as_millis() as u64;
419        let (memory_peak, cpu_time_us) = self.get_resource_usage().unwrap_or((0, 0));
420
421        let sandbox_result = SandboxResult {
422            exit_code: process_result.exit_status,
423            signal: process_result.signal,
424            timed_out: false,
425            memory_peak,
426            cpu_time_us,
427            wall_time_ms,
428        };
429
430        let stream =
431            stream.ok_or_else(|| SandboxError::Io(std::io::Error::other("stream unavailable")))?;
432
433        Ok((sandbox_result, stream))
434    }
435
436    pub fn kill(&mut self) -> Result<()> {
437        if let Some(pid) = self.pid {
438            kill(pid, Signal::SIGKILL)
439                .map_err(|e| SandboxError::Syscall(format!("Failed to kill process: {}", e)))?;
440            self.pid = None;
441        }
442        Ok(())
443    }
444
445    /// Get resource usage
446    pub fn get_resource_usage(&self) -> Result<(u64, u64)> {
447        if let Some(ref cgroup) = self.cgroup {
448            let memory = cgroup.get_memory_usage()?;
449            let cpu = cgroup.get_cpu_usage()?;
450            Ok((memory, cpu))
451        } else {
452            // In unprivileged mode without cgroups, we can't get precise usage
453            Ok((0, 0))
454        }
455    }
456}
457
458impl Drop for Sandbox {
459    fn drop(&mut self) {
460        let _ = self.kill();
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use tempfile::tempdir;
468
469    fn config_with_temp_root(id: &str) -> (tempfile::TempDir, SandboxConfig) {
470        let tmp = tempdir().unwrap();
471        let config = SandboxConfig {
472            id: id.to_string(),
473            root: tmp.path().join("root"),
474            namespace_config: NamespaceConfig::minimal(),
475            ..Default::default()
476        };
477        (tmp, config)
478    }
479
480    #[test]
481    fn test_sandbox_config_default() {
482        let config = SandboxConfig::default();
483        assert_eq!(config.id, "default");
484        assert!(config.memory_limit.is_none());
485        assert_eq!(config.privilege_mode, PrivilegeMode::Auto);
486    }
487
488    #[test]
489    fn test_sandbox_config_validate_empty_id() {
490        let config = SandboxConfig {
491            id: String::new(),
492            ..Default::default()
493        };
494        assert!(config.validate_invariants().is_err());
495    }
496
497    #[test]
498    fn test_sandbox_config_validate_no_namespaces() {
499        let config = SandboxConfig {
500            namespace_config: NamespaceConfig {
501                pid: false,
502                ipc: false,
503                net: false,
504                mount: false,
505                uts: false,
506                user: false,
507            },
508            ..Default::default()
509        };
510        assert!(config.validate_invariants().is_err());
511    }
512
513    #[test]
514    fn test_sandbox_builder_new() {
515        let builder = SandboxBuilder::new("test");
516        assert_eq!(builder.config.id, "test");
517    }
518
519    #[test]
520    fn test_sandbox_builder_memory_limit() {
521        let builder = SandboxBuilder::new("test").memory_limit(100 * 1024 * 1024);
522        assert_eq!(builder.config.memory_limit, Some(100 * 1024 * 1024));
523    }
524
525    #[test]
526    fn test_sandbox_builder_memory_limit_str() -> Result<()> {
527        let builder = SandboxBuilder::new("test").memory_limit_str("100M")?;
528        assert_eq!(builder.config.memory_limit, Some(100 * 1024 * 1024));
529        Ok(())
530    }
531
532    #[test]
533    fn test_sandbox_builder_cpu_limit() {
534        let builder = SandboxBuilder::new("test").cpu_limit_percent(50);
535        assert!(builder.config.cpu_quota.is_some());
536    }
537
538    #[test]
539    fn test_sandbox_builder_cpu_limit_zero() {
540        let builder = SandboxBuilder::new("test").cpu_limit_percent(0);
541        assert!(builder.config.cpu_quota.is_none());
542    }
543
544    #[test]
545    fn test_sandbox_builder_cpu_limit_over_100() {
546        let builder = SandboxBuilder::new("test").cpu_limit_percent(150);
547        assert!(builder.config.cpu_quota.is_none());
548    }
549
550    #[test]
551    fn test_sandbox_builder_privilege_mode() {
552        let builder = SandboxBuilder::new("test").privilege_mode(PrivilegeMode::Unprivileged);
553        assert_eq!(builder.config.privilege_mode, PrivilegeMode::Unprivileged);
554    }
555
556    #[test]
557    fn test_sandbox_builder_build_creates_sandbox() {
558        let tmp = tempdir().unwrap();
559        let sandbox = SandboxBuilder::new("build-test").root(tmp.path()).build();
560        // Should succeed without root in Auto mode (falls back to unprivileged)
561        assert!(sandbox.is_ok());
562    }
563
564    #[test]
565    fn test_sandbox_builder_build_validates_config() {
566        let tmp = tempdir().unwrap();
567        let result = SandboxBuilder::new("").root(tmp.path()).build();
568        assert!(result.is_err());
569    }
570
571    #[test]
572    fn sandbox_provides_id_and_root() {
573        let (_tmp, config) = config_with_temp_root("sand-id");
574        let sandbox = Sandbox::new(config).unwrap();
575        assert_eq!(sandbox.id(), "sand-id");
576        assert!(sandbox.root().ends_with("root"));
577        assert!(!sandbox.is_running());
578    }
579
580    #[test]
581    fn sandbox_run_returns_error_if_already_running() {
582        let (_tmp, config) = config_with_temp_root("already-running");
583        let mut sandbox = Sandbox::new(config).unwrap();
584        sandbox.pid = Some(Pid::from_raw(1));
585
586        let args: [&str; 1] = ["test"];
587        let result = sandbox.run("/bin/echo", &args);
588        assert!(result.is_err());
589        assert!(result.unwrap_err().to_string().contains("already running"));
590    }
591
592    #[test]
593    fn sandbox_kill_handles_missing_pid() {
594        let (_tmp, config) = config_with_temp_root("kill-test");
595        let mut sandbox = Sandbox::new(config).unwrap();
596        sandbox.kill().unwrap();
597    }
598
599    #[test]
600    fn sandbox_kill_terminates_real_process() {
601        let (_tmp, config) = config_with_temp_root("kill-proc");
602        let mut sandbox = Sandbox::new(config).unwrap();
603        let mut child = std::process::Command::new("sleep")
604            .arg("1")
605            .spawn()
606            .unwrap();
607        sandbox.pid = Some(Pid::from_raw(child.id() as i32));
608        sandbox.kill().unwrap();
609        let _ = child.wait();
610    }
611
612    #[test]
613    fn sandbox_get_resource_usage_without_cgroup_returns_zeros() {
614        let (_tmp, config) = config_with_temp_root("no-cgroup");
615        let sandbox = Sandbox::new(config).unwrap();
616        // In unprivileged mode without cgroups, returns (0, 0) instead of error
617        let result = sandbox.get_resource_usage();
618        assert!(result.is_ok());
619        assert_eq!(result.unwrap(), (0, 0));
620    }
621
622    #[test]
623    fn sandbox_reports_resource_usage_from_cgroup() {
624        let (tmp, mut config) = config_with_temp_root("resource-test");
625        config.root = tmp.path().join("root");
626        let mut sandbox = Sandbox::new(config).unwrap();
627
628        let cg_path = tmp.path().join("cgroup");
629        std::fs::create_dir_all(&cg_path).unwrap();
630        std::fs::write(cg_path.join("memory.current"), "1234").unwrap();
631        std::fs::write(cg_path.join("cpu.stat"), "usage_usec 77\n").unwrap();
632
633        sandbox.cgroup = Some(Cgroup::for_testing(cg_path));
634        let (mem, cpu) = sandbox.get_resource_usage().unwrap();
635        assert_eq!(mem, 1234);
636        assert_eq!(cpu, 77);
637    }
638
639    #[test]
640    fn test_sandbox_result_killed_by_seccomp() {
641        let result = SandboxResult {
642            exit_code: 159,
643            signal: None,
644            timed_out: false,
645            memory_peak: 0,
646            cpu_time_us: 0,
647            wall_time_ms: 0,
648        };
649        assert!(result.killed_by_seccomp());
650    }
651
652    #[test]
653    fn test_sandbox_result_not_killed_by_seccomp() {
654        let result = SandboxResult {
655            exit_code: 0,
656            signal: None,
657            timed_out: false,
658            memory_peak: 0,
659            cpu_time_us: 0,
660            wall_time_ms: 0,
661        };
662        assert!(!result.killed_by_seccomp());
663    }
664
665    #[test]
666    fn test_sandbox_result_check_seccomp_error() {
667        let result = SandboxResult {
668            exit_code: 159,
669            signal: None,
670            timed_out: false,
671            memory_peak: 0,
672            cpu_time_us: 0,
673            wall_time_ms: 0,
674        };
675        assert!(result.check_seccomp_error().is_err());
676
677        let ok_result = SandboxResult {
678            exit_code: 0,
679            signal: None,
680            timed_out: false,
681            memory_peak: 0,
682            cpu_time_us: 0,
683            wall_time_ms: 0,
684        };
685        assert!(ok_result.check_seccomp_error().is_ok());
686    }
687}