Skip to main content

a3s_box_core/
execution.rs

1//! Backend-neutral execution isolation resolution.
2
3use serde::{Deserialize, Serialize};
4
5use crate::config::{BoxConfig, ExecutionIsolation, TeeConfig};
6use crate::error::{BoxError, Result};
7use crate::network::NetworkMode;
8
9/// Concrete backend selected for an execution request.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum ExecutionBackend {
13    /// libkrun-backed MicroVM execution.
14    Krun,
15    /// OCI execution through the certified crun runtime.
16    Crun,
17}
18
19/// Security boundary provided by the resolved backend.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum IsolationClass {
23    /// A hardware-backed virtual-machine boundary.
24    HardwareVm,
25    /// Linux namespaces and controls sharing the host kernel.
26    SharedKernel,
27}
28
29/// Deterministic result of resolving one execution request.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ResolvedExecutionPlan {
32    /// Isolation requested by the caller or selected by the implicit default.
33    pub requested_isolation: ExecutionIsolation,
34    /// Concrete runtime backend.
35    pub backend: ExecutionBackend,
36    /// Effective security-boundary class.
37    pub isolation_class: IsolationClass,
38    /// Controls that the selected backend must prove before launch.
39    pub required_controls: Vec<String>,
40}
41
42const SANDBOX_REQUIRED_CONTROLS: &[&str] = &[
43    "user-namespace",
44    "mount-namespace",
45    "pid-namespace",
46    "ipc-namespace",
47    "uts-namespace",
48    "network-namespace",
49    "seccomp",
50    "capability-bounding-set",
51    "no-new-privileges",
52    "cgroup-v2",
53];
54
55const SANDBOX_ALLOWED_ADDED_CAPABILITIES: &[&str] = &[
56    "AUDIT_WRITE",
57    "CHOWN",
58    "DAC_OVERRIDE",
59    "FOWNER",
60    "FSETID",
61    "KILL",
62    "MKNOD",
63    "NET_BIND_SERVICE",
64    "SETFCAP",
65    "SETGID",
66    "SETPCAP",
67    "SETUID",
68    "SYS_CHROOT",
69];
70
71/// Resolve a box configuration without probing or mutating the host.
72///
73/// Host capabilities are checked separately immediately before preparation.
74/// Keeping this function pure makes unsupported feature combinations fail
75/// before image pulls, rootfs mounts, state changes, or runtime processes.
76pub fn resolve_execution(config: &BoxConfig) -> Result<ResolvedExecutionPlan> {
77    match config.isolation {
78        ExecutionIsolation::Microvm => Ok(ResolvedExecutionPlan {
79            requested_isolation: ExecutionIsolation::Microvm,
80            backend: ExecutionBackend::Krun,
81            isolation_class: IsolationClass::HardwareVm,
82            required_controls: Vec::new(),
83        }),
84        ExecutionIsolation::Sandbox => {
85            validate_sandbox_compatibility(config)?;
86            Ok(ResolvedExecutionPlan {
87                requested_isolation: ExecutionIsolation::Sandbox,
88                backend: ExecutionBackend::Crun,
89                isolation_class: IsolationClass::SharedKernel,
90                required_controls: SANDBOX_REQUIRED_CONTROLS
91                    .iter()
92                    .map(|control| (*control).to_string())
93                    .collect(),
94            })
95        }
96    }
97}
98
99/// Validate features that cannot be represented safely by the sandbox MVP.
100pub fn validate_sandbox_compatibility(config: &BoxConfig) -> Result<()> {
101    if !config.isolation.is_sandbox() {
102        return Ok(());
103    }
104
105    let mut unsupported = Vec::new();
106
107    if !matches!(config.tee, TeeConfig::None) {
108        unsupported.push("TEE and attestation");
109    }
110    if config.pool.enabled || config.pool.snapshot_fork {
111        unsupported.push("warm pools and snapshot-fork");
112    }
113    if config.deferred_main {
114        unsupported.push("deferred main execution");
115    }
116    if config.ksm {
117        unsupported.push("KSM");
118    }
119    if config.snapshot_mem_file.is_some()
120        || config.snapshot_sock.is_some()
121        || config.restore_from.is_some()
122    {
123        unsupported.push("VM snapshots and restore");
124    }
125    if config.privileged {
126        unsupported.push("privileged mode");
127    }
128    if config.sidecar.is_some() {
129        unsupported.push("vsock sidecars");
130    }
131    if !config.port_map.is_empty() {
132        unsupported.push("published ports");
133    }
134    if matches!(config.network, NetworkMode::Bridge { .. }) {
135        unsupported.push("named bridge networking");
136    }
137    if !config.sysctls.is_empty() {
138        unsupported.push("custom sysctls");
139    }
140    if config
141        .security_opt
142        .iter()
143        .any(|option| option.trim().eq_ignore_ascii_case("seccomp=unconfined"))
144    {
145        unsupported.push("unconfined seccomp");
146    }
147
148    let disallowed_capabilities: Vec<String> = config
149        .cap_add
150        .iter()
151        .map(|capability| normalize_capability(capability))
152        .filter(|capability| !SANDBOX_ALLOWED_ADDED_CAPABILITIES.contains(&capability.as_str()))
153        .collect();
154    if !disallowed_capabilities.is_empty() {
155        return Err(BoxError::ConfigError(format!(
156            "sandbox isolation rejects added capabilities outside its allowlist: {}",
157            disallowed_capabilities.join(", ")
158        )));
159    }
160
161    if unsupported.is_empty() {
162        Ok(())
163    } else {
164        Err(BoxError::ConfigError(format!(
165            "sandbox isolation does not support: {}",
166            unsupported.join(", ")
167        )))
168    }
169}
170
171fn normalize_capability(capability: &str) -> String {
172    let normalized = capability.trim().to_ascii_uppercase();
173    normalized
174        .strip_prefix("CAP_")
175        .unwrap_or(&normalized)
176        .to_string()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::config::{PoolConfig, SidecarConfig};
183
184    fn sandbox_config() -> BoxConfig {
185        BoxConfig {
186            isolation: ExecutionIsolation::Sandbox,
187            ..Default::default()
188        }
189    }
190
191    #[test]
192    fn default_resolves_only_to_krun_hardware_vm() {
193        let plan = resolve_execution(&BoxConfig::default()).unwrap();
194        assert_eq!(plan.backend, ExecutionBackend::Krun);
195        assert_eq!(plan.isolation_class, IsolationClass::HardwareVm);
196        assert!(plan.required_controls.is_empty());
197    }
198
199    #[test]
200    fn sandbox_resolves_to_crun_shared_kernel_with_mandatory_controls() {
201        let plan = resolve_execution(&sandbox_config()).unwrap();
202        assert_eq!(plan.backend, ExecutionBackend::Crun);
203        assert_eq!(plan.isolation_class, IsolationClass::SharedKernel);
204        for required in SANDBOX_REQUIRED_CONTROLS {
205            assert!(plan.required_controls.iter().any(|value| value == required));
206        }
207    }
208
209    #[test]
210    fn sandbox_rejects_vm_only_features_together() {
211        let config = BoxConfig {
212            isolation: ExecutionIsolation::Sandbox,
213            tee: TeeConfig::Tdx {
214                workload_id: "test".to_string(),
215                simulate: true,
216            },
217            pool: PoolConfig {
218                enabled: true,
219                ..Default::default()
220            },
221            sidecar: Some(SidecarConfig::default()),
222            port_map: vec!["8080:80".to_string()],
223            privileged: true,
224            ..Default::default()
225        };
226
227        let error = resolve_execution(&config).unwrap_err().to_string();
228        assert!(error.contains("TEE and attestation"));
229        assert!(error.contains("warm pools"));
230        assert!(error.contains("vsock sidecars"));
231        assert!(error.contains("published ports"));
232        assert!(error.contains("privileged mode"));
233    }
234
235    #[test]
236    fn sandbox_rejects_unconfined_seccomp() {
237        let config = BoxConfig {
238            security_opt: vec!["seccomp=unconfined".to_string()],
239            ..sandbox_config()
240        };
241        assert!(resolve_execution(&config)
242            .unwrap_err()
243            .to_string()
244            .contains("unconfined seccomp"));
245    }
246
247    #[test]
248    fn sandbox_normalizes_and_allows_baseline_capabilities() {
249        let config = BoxConfig {
250            cap_add: vec!["cap_chown".to_string(), "NET_BIND_SERVICE".to_string()],
251            ..sandbox_config()
252        };
253        assert!(resolve_execution(&config).is_ok());
254    }
255
256    #[test]
257    fn sandbox_rejects_powerful_added_capability() {
258        let config = BoxConfig {
259            cap_add: vec!["CAP_SYS_ADMIN".to_string()],
260            ..sandbox_config()
261        };
262        let error = resolve_execution(&config).unwrap_err().to_string();
263        assert!(error.contains("SYS_ADMIN"));
264    }
265}