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 => {
79            validate_microvm_compatibility(config)?;
80            Ok(ResolvedExecutionPlan {
81                requested_isolation: ExecutionIsolation::Microvm,
82                backend: ExecutionBackend::Krun,
83                isolation_class: IsolationClass::HardwareVm,
84                required_controls: Vec::new(),
85            })
86        }
87        ExecutionIsolation::Sandbox => {
88            validate_sandbox_compatibility(config)?;
89            Ok(ResolvedExecutionPlan {
90                requested_isolation: ExecutionIsolation::Sandbox,
91                backend: ExecutionBackend::Crun,
92                isolation_class: IsolationClass::SharedKernel,
93                required_controls: SANDBOX_REQUIRED_CONTROLS
94                    .iter()
95                    .map(|control| (*control).to_string())
96                    .collect(),
97            })
98        }
99    }
100}
101
102/// Validate features that cannot be represented safely by the MicroVM backend.
103pub fn validate_microvm_compatibility(config: &BoxConfig) -> Result<()> {
104    if config.isolation != ExecutionIsolation::Microvm {
105        return Ok(());
106    }
107
108    validate_security_options(config, SecurityBackend::Microvm)
109}
110
111/// Validate features that cannot be represented safely by the sandbox MVP.
112pub fn validate_sandbox_compatibility(config: &BoxConfig) -> Result<()> {
113    if !config.isolation.is_sandbox() {
114        return Ok(());
115    }
116
117    validate_security_options(config, SecurityBackend::Sandbox)?;
118
119    let mut unsupported = Vec::new();
120
121    if !matches!(config.tee, TeeConfig::None) {
122        unsupported.push("TEE and attestation");
123    }
124    if config.pool.enabled || config.pool.snapshot_fork {
125        unsupported.push("warm pools and snapshot-fork");
126    }
127    if config.deferred_main {
128        unsupported.push("deferred main execution");
129    }
130    if config.ksm {
131        unsupported.push("KSM");
132    }
133    if config.snapshot_mem_file.is_some()
134        || config.snapshot_sock.is_some()
135        || config.restore_from.is_some()
136    {
137        unsupported.push("VM snapshots and restore");
138    }
139    if config.privileged {
140        unsupported.push("privileged mode");
141    }
142    if config.sidecar.is_some() {
143        unsupported.push("vsock sidecars");
144    }
145    if !config.port_map.is_empty() {
146        unsupported.push("published ports");
147    }
148    if matches!(config.network, NetworkMode::Bridge { .. }) {
149        unsupported.push("named bridge networking");
150    }
151    if !config.sysctls.is_empty() {
152        unsupported.push("custom sysctls");
153    }
154    let disallowed_capabilities: Vec<String> = config
155        .cap_add
156        .iter()
157        .map(|capability| normalize_capability(capability))
158        .filter(|capability| !SANDBOX_ALLOWED_ADDED_CAPABILITIES.contains(&capability.as_str()))
159        .collect();
160    if !disallowed_capabilities.is_empty() {
161        return Err(BoxError::ConfigError(format!(
162            "sandbox isolation rejects added capabilities outside its allowlist: {}",
163            disallowed_capabilities.join(", ")
164        )));
165    }
166
167    if unsupported.is_empty() {
168        Ok(())
169    } else {
170        Err(BoxError::ConfigError(format!(
171            "sandbox isolation does not support: {}",
172            unsupported.join(", ")
173        )))
174    }
175}
176
177#[derive(Debug, Clone, Copy)]
178enum SecurityBackend {
179    Microvm,
180    Sandbox,
181}
182
183impl SecurityBackend {
184    fn label(self) -> &'static str {
185        match self {
186            Self::Microvm => "microVM",
187            Self::Sandbox => "sandbox",
188        }
189    }
190}
191
192fn validate_security_options(config: &BoxConfig, backend: SecurityBackend) -> Result<()> {
193    for raw_option in &config.security_opt {
194        let option = raw_option.trim();
195        if option.is_empty() {
196            return Err(BoxError::ConfigError(format!(
197                "{} isolation does not accept an empty security option",
198                backend.label()
199            )));
200        }
201
202        if option.eq_ignore_ascii_case("no-new-privileges") {
203            continue;
204        }
205
206        let Some((key, value)) = option.split_once('=') else {
207            return Err(unsupported_security_option(backend, option));
208        };
209        let key = key.trim();
210        let value = value.trim();
211
212        if key.eq_ignore_ascii_case("seccomp") {
213            if value.eq_ignore_ascii_case("default") {
214                continue;
215            }
216            if value.eq_ignore_ascii_case("unconfined") {
217                if matches!(backend, SecurityBackend::Microvm) {
218                    continue;
219                }
220                return Err(BoxError::ConfigError(
221                    "sandbox isolation does not support unconfined seccomp".to_string(),
222                ));
223            }
224            if value.is_empty() {
225                return Err(BoxError::ConfigError(format!(
226                    "{} isolation requires a seccomp mode",
227                    backend.label()
228                )));
229            }
230            return Err(BoxError::ConfigError(format!(
231                "{} isolation does not support custom seccomp profile '{}'",
232                backend.label(),
233                value
234            )));
235        }
236
237        if key.eq_ignore_ascii_case("no-new-privileges") {
238            if value.eq_ignore_ascii_case("true") {
239                continue;
240            }
241            if value.eq_ignore_ascii_case("false") {
242                if matches!(backend, SecurityBackend::Microvm) {
243                    continue;
244                }
245                return Err(BoxError::ConfigError(
246                    "sandbox isolation requires no-new-privileges and cannot disable it"
247                        .to_string(),
248                ));
249            }
250            return Err(BoxError::ConfigError(format!(
251                "{} isolation requires no-new-privileges to be true or false, got '{}'",
252                backend.label(),
253                value
254            )));
255        }
256
257        if key.eq_ignore_ascii_case("apparmor") {
258            return Err(BoxError::ConfigError(format!(
259                "{} isolation does not support AppArmor security option '{}'",
260                backend.label(),
261                option
262            )));
263        }
264
265        if key.eq_ignore_ascii_case("label") {
266            return Err(BoxError::ConfigError(format!(
267                "{} isolation does not support SELinux label security option '{}'",
268                backend.label(),
269                option
270            )));
271        }
272
273        return Err(unsupported_security_option(backend, option));
274    }
275
276    Ok(())
277}
278
279fn unsupported_security_option(backend: SecurityBackend, option: &str) -> BoxError {
280    BoxError::ConfigError(format!(
281        "{} isolation does not support security option '{}'",
282        backend.label(),
283        option
284    ))
285}
286
287fn normalize_capability(capability: &str) -> String {
288    let normalized = capability.trim().to_ascii_uppercase();
289    normalized
290        .strip_prefix("CAP_")
291        .unwrap_or(&normalized)
292        .to_string()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::config::{PoolConfig, SidecarConfig};
299
300    fn sandbox_config() -> BoxConfig {
301        BoxConfig {
302            isolation: ExecutionIsolation::Sandbox,
303            ..Default::default()
304        }
305    }
306
307    #[test]
308    fn default_resolves_only_to_krun_hardware_vm() {
309        let plan = resolve_execution(&BoxConfig::default()).unwrap();
310        assert_eq!(plan.backend, ExecutionBackend::Krun);
311        assert_eq!(plan.isolation_class, IsolationClass::HardwareVm);
312        assert!(plan.required_controls.is_empty());
313    }
314
315    #[test]
316    fn microvm_rejects_host_kernel_and_custom_security_profiles() {
317        for (option, expected) in [
318            ("apparmor=runtime/default", "AppArmor"),
319            ("label=type:container_t", "SELinux"),
320            ("seccomp=/profiles/restricted.json", "custom seccomp"),
321            ("systempaths=unconfined", "security option"),
322        ] {
323            let config = BoxConfig {
324                security_opt: vec![option.to_string()],
325                ..Default::default()
326            };
327            let error = resolve_execution(&config).unwrap_err().to_string();
328            assert!(
329                error.contains(expected),
330                "expected {option:?} rejection to mention {expected:?}, got {error:?}"
331            );
332        }
333    }
334
335    #[test]
336    fn microvm_accepts_guest_enforceable_security_options() {
337        let config = BoxConfig {
338            security_opt: vec![
339                " SECCOMP=DEFAULT ".to_string(),
340                "seccomp=unconfined".to_string(),
341                "no-new-privileges".to_string(),
342                "no-new-privileges=false".to_string(),
343            ],
344            cap_add: vec!["NET_ADMIN".to_string()],
345            cap_drop: vec!["NET_RAW".to_string()],
346            privileged: true,
347            ..Default::default()
348        };
349
350        assert!(resolve_execution(&config).is_ok());
351    }
352
353    #[test]
354    fn sandbox_resolves_to_crun_shared_kernel_with_mandatory_controls() {
355        let plan = resolve_execution(&sandbox_config()).unwrap();
356        assert_eq!(plan.backend, ExecutionBackend::Crun);
357        assert_eq!(plan.isolation_class, IsolationClass::SharedKernel);
358        for required in SANDBOX_REQUIRED_CONTROLS {
359            assert!(plan.required_controls.iter().any(|value| value == required));
360        }
361    }
362
363    #[test]
364    fn sandbox_rejects_vm_only_features_together() {
365        let config = BoxConfig {
366            isolation: ExecutionIsolation::Sandbox,
367            tee: TeeConfig::Tdx {
368                workload_id: "test".to_string(),
369                simulate: true,
370            },
371            pool: PoolConfig {
372                enabled: true,
373                ..Default::default()
374            },
375            sidecar: Some(SidecarConfig::default()),
376            port_map: vec!["8080:80".to_string()],
377            privileged: true,
378            ..Default::default()
379        };
380
381        let error = resolve_execution(&config).unwrap_err().to_string();
382        assert!(error.contains("TEE and attestation"));
383        assert!(error.contains("warm pools"));
384        assert!(error.contains("vsock sidecars"));
385        assert!(error.contains("published ports"));
386        assert!(error.contains("privileged mode"));
387    }
388
389    #[test]
390    fn sandbox_rejects_unconfined_seccomp() {
391        let config = BoxConfig {
392            security_opt: vec!["seccomp=unconfined".to_string()],
393            ..sandbox_config()
394        };
395        assert!(resolve_execution(&config)
396            .unwrap_err()
397            .to_string()
398            .contains("unconfined seccomp"));
399    }
400
401    #[test]
402    fn sandbox_rejects_security_options_not_wired_to_oci() {
403        for (option, expected) in [
404            ("apparmor=runtime/default", "AppArmor"),
405            ("label=type:container_t", "SELinux"),
406            ("seccomp=/profiles/restricted.json", "custom seccomp"),
407            ("no-new-privileges=false", "requires no-new-privileges"),
408        ] {
409            let config = BoxConfig {
410                security_opt: vec![option.to_string()],
411                ..sandbox_config()
412            };
413            let error = resolve_execution(&config).unwrap_err().to_string();
414            assert!(
415                error.contains(expected),
416                "expected {option:?} rejection to mention {expected:?}, got {error:?}"
417            );
418        }
419    }
420
421    #[test]
422    fn sandbox_accepts_security_options_compiled_by_oci_backend() {
423        let config = BoxConfig {
424            security_opt: vec![
425                "seccomp=default".to_string(),
426                "no-new-privileges".to_string(),
427                "no-new-privileges=true".to_string(),
428            ],
429            cap_add: vec!["cap_chown".to_string()],
430            cap_drop: vec!["NET_RAW".to_string()],
431            ..sandbox_config()
432        };
433
434        assert!(resolve_execution(&config).is_ok());
435    }
436
437    #[test]
438    fn sandbox_normalizes_and_allows_baseline_capabilities() {
439        let config = BoxConfig {
440            cap_add: vec!["cap_chown".to_string(), "NET_BIND_SERVICE".to_string()],
441            ..sandbox_config()
442        };
443        assert!(resolve_execution(&config).is_ok());
444    }
445
446    #[test]
447    fn sandbox_rejects_powerful_added_capability() {
448        let config = BoxConfig {
449            cap_add: vec!["CAP_SYS_ADMIN".to_string()],
450            ..sandbox_config()
451        };
452        let error = resolve_execution(&config).unwrap_err().to_string();
453        assert!(error.contains("SYS_ADMIN"));
454    }
455}