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 = "kebab-case")]
12pub enum ExecutionBackend {
13    /// libkrun-backed MicroVM execution.
14    Krun,
15    /// Shared-kernel execution through A3S OCI Runtime.
16    A3sOci,
17}
18
19impl ExecutionBackend {
20    /// Whether this backend provides shared-kernel Sandbox execution.
21    pub const fn is_sandbox(self) -> bool {
22        matches!(self, Self::A3sOci)
23    }
24}
25
26/// Security boundary provided by the resolved backend.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "kebab-case")]
29pub enum IsolationClass {
30    /// A hardware-backed virtual-machine boundary.
31    HardwareVm,
32    /// Linux namespaces and controls sharing the host kernel.
33    SharedKernel,
34}
35
36/// Deterministic result of resolving one execution request.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ResolvedExecutionPlan {
39    /// Isolation requested by the caller or selected by the implicit default.
40    pub requested_isolation: ExecutionIsolation,
41    /// Concrete runtime backend.
42    pub backend: ExecutionBackend,
43    /// Effective security-boundary class.
44    pub isolation_class: IsolationClass,
45    /// Controls that the selected backend must prove before launch.
46    pub required_controls: Vec<String>,
47}
48
49const SANDBOX_REQUIRED_CONTROLS: &[&str] = &[
50    "user-namespace",
51    "mount-namespace",
52    "pid-namespace",
53    "ipc-namespace",
54    "uts-namespace",
55    "network-namespace",
56    "seccomp",
57    "capability-bounding-set",
58    "no-new-privileges",
59    "cgroup-v2",
60];
61
62const SANDBOX_ALLOWED_ADDED_CAPABILITIES: &[&str] = &[
63    "AUDIT_WRITE",
64    "CHOWN",
65    "DAC_OVERRIDE",
66    "FOWNER",
67    "FSETID",
68    "KILL",
69    "MKNOD",
70    "NET_BIND_SERVICE",
71    "SETFCAP",
72    "SETGID",
73    "SETPCAP",
74    "SETUID",
75    "SYS_CHROOT",
76];
77
78/// Resolve a box configuration without probing or mutating the host.
79///
80/// Host capabilities are checked separately immediately before preparation.
81/// Keeping this function pure makes unsupported feature combinations fail
82/// before image pulls, rootfs mounts, state changes, or runtime processes.
83pub fn resolve_execution(config: &BoxConfig) -> Result<ResolvedExecutionPlan> {
84    match config.isolation {
85        ExecutionIsolation::Microvm => {
86            validate_microvm_compatibility(config)?;
87            Ok(ResolvedExecutionPlan {
88                requested_isolation: ExecutionIsolation::Microvm,
89                backend: ExecutionBackend::Krun,
90                isolation_class: IsolationClass::HardwareVm,
91                required_controls: Vec::new(),
92            })
93        }
94        ExecutionIsolation::Sandbox => {
95            validate_sandbox_compatibility(config)?;
96            Ok(ResolvedExecutionPlan {
97                requested_isolation: ExecutionIsolation::Sandbox,
98                backend: ExecutionBackend::A3sOci,
99                isolation_class: IsolationClass::SharedKernel,
100                required_controls: SANDBOX_REQUIRED_CONTROLS
101                    .iter()
102                    .map(|control| (*control).to_string())
103                    .collect(),
104            })
105        }
106    }
107}
108
109/// Validate features that cannot be represented safely by the MicroVM backend.
110pub fn validate_microvm_compatibility(config: &BoxConfig) -> Result<()> {
111    if config.isolation != ExecutionIsolation::Microvm {
112        return Ok(());
113    }
114
115    validate_security_options(config, SecurityBackend::Microvm)
116}
117
118/// Validate features that cannot be represented safely by the sandbox MVP.
119pub fn validate_sandbox_compatibility(config: &BoxConfig) -> Result<()> {
120    if !config.isolation.is_sandbox() {
121        return Ok(());
122    }
123
124    validate_security_options(config, SecurityBackend::Sandbox)?;
125
126    let mut unsupported = Vec::new();
127
128    if !matches!(config.tee, TeeConfig::None) {
129        unsupported.push("TEE and attestation");
130    }
131    if config.pool.enabled || config.pool.snapshot_fork {
132        unsupported.push("warm pools and snapshot-fork");
133    }
134    if config.deferred_main {
135        unsupported.push("deferred main execution");
136    }
137    if config.ksm {
138        unsupported.push("KSM");
139    }
140    if config.snapshot_mem_file.is_some()
141        || config.snapshot_sock.is_some()
142        || config.restore_from.is_some()
143    {
144        unsupported.push("VM snapshots and restore");
145    }
146    if config.privileged {
147        unsupported.push("privileged mode");
148    }
149    if config.sidecar.is_some() {
150        unsupported.push("vsock sidecars");
151    }
152    if !config.port_map.is_empty() {
153        unsupported.push("published ports");
154    }
155    if matches!(config.network, NetworkMode::Bridge { .. }) {
156        unsupported.push("named bridge networking");
157    }
158    if !config.sysctls.is_empty() {
159        unsupported.push("custom sysctls");
160    }
161    let disallowed_capabilities: Vec<String> = config
162        .cap_add
163        .iter()
164        .map(|capability| normalize_capability(capability))
165        .filter(|capability| !SANDBOX_ALLOWED_ADDED_CAPABILITIES.contains(&capability.as_str()))
166        .collect();
167    if !disallowed_capabilities.is_empty() {
168        return Err(BoxError::ConfigError(format!(
169            "sandbox isolation rejects added capabilities outside its allowlist: {}",
170            disallowed_capabilities.join(", ")
171        )));
172    }
173
174    if unsupported.is_empty() {
175        Ok(())
176    } else {
177        Err(BoxError::ConfigError(format!(
178            "sandbox isolation does not support: {}",
179            unsupported.join(", ")
180        )))
181    }
182}
183
184#[derive(Debug, Clone, Copy)]
185enum SecurityBackend {
186    Microvm,
187    Sandbox,
188}
189
190impl SecurityBackend {
191    fn label(self) -> &'static str {
192        match self {
193            Self::Microvm => "microVM",
194            Self::Sandbox => "sandbox",
195        }
196    }
197}
198
199fn validate_security_options(config: &BoxConfig, backend: SecurityBackend) -> Result<()> {
200    for raw_option in &config.security_opt {
201        let option = raw_option.trim();
202        if option.is_empty() {
203            return Err(BoxError::ConfigError(format!(
204                "{} isolation does not accept an empty security option",
205                backend.label()
206            )));
207        }
208
209        if option.eq_ignore_ascii_case("no-new-privileges") {
210            continue;
211        }
212
213        let Some((key, value)) = option.split_once('=') else {
214            return Err(unsupported_security_option(backend, option));
215        };
216        let key = key.trim();
217        let value = value.trim();
218
219        if key.eq_ignore_ascii_case("seccomp") {
220            if value.eq_ignore_ascii_case("default") {
221                continue;
222            }
223            if value.eq_ignore_ascii_case("unconfined") {
224                if matches!(backend, SecurityBackend::Microvm) {
225                    continue;
226                }
227                return Err(BoxError::ConfigError(
228                    "sandbox isolation does not support unconfined seccomp".to_string(),
229                ));
230            }
231            if value.is_empty() {
232                return Err(BoxError::ConfigError(format!(
233                    "{} isolation requires a seccomp mode",
234                    backend.label()
235                )));
236            }
237            return Err(BoxError::ConfigError(format!(
238                "{} isolation does not support custom seccomp profile '{}'",
239                backend.label(),
240                value
241            )));
242        }
243
244        if key.eq_ignore_ascii_case("no-new-privileges") {
245            if value.eq_ignore_ascii_case("true") {
246                continue;
247            }
248            if value.eq_ignore_ascii_case("false") {
249                if matches!(backend, SecurityBackend::Microvm) {
250                    continue;
251                }
252                return Err(BoxError::ConfigError(
253                    "sandbox isolation requires no-new-privileges and cannot disable it"
254                        .to_string(),
255                ));
256            }
257            return Err(BoxError::ConfigError(format!(
258                "{} isolation requires no-new-privileges to be true or false, got '{}'",
259                backend.label(),
260                value
261            )));
262        }
263
264        if key.eq_ignore_ascii_case("apparmor") {
265            return Err(BoxError::ConfigError(format!(
266                "{} isolation does not support AppArmor security option '{}'",
267                backend.label(),
268                option
269            )));
270        }
271
272        if key.eq_ignore_ascii_case("label") {
273            return Err(BoxError::ConfigError(format!(
274                "{} isolation does not support SELinux label security option '{}'",
275                backend.label(),
276                option
277            )));
278        }
279
280        return Err(unsupported_security_option(backend, option));
281    }
282
283    Ok(())
284}
285
286fn unsupported_security_option(backend: SecurityBackend, option: &str) -> BoxError {
287    BoxError::ConfigError(format!(
288        "{} isolation does not support security option '{}'",
289        backend.label(),
290        option
291    ))
292}
293
294fn normalize_capability(capability: &str) -> String {
295    let normalized = capability.trim().to_ascii_uppercase();
296    normalized
297        .strip_prefix("CAP_")
298        .unwrap_or(&normalized)
299        .to_string()
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::config::{PoolConfig, SidecarConfig};
306
307    fn sandbox_config() -> BoxConfig {
308        BoxConfig {
309            isolation: ExecutionIsolation::Sandbox,
310            ..Default::default()
311        }
312    }
313
314    #[test]
315    fn default_resolves_only_to_krun_hardware_vm() {
316        let plan = resolve_execution(&BoxConfig::default()).unwrap();
317        assert_eq!(plan.backend, ExecutionBackend::Krun);
318        assert_eq!(plan.isolation_class, IsolationClass::HardwareVm);
319        assert!(plan.required_controls.is_empty());
320    }
321
322    #[test]
323    fn microvm_rejects_host_kernel_and_custom_security_profiles() {
324        for (option, expected) in [
325            ("apparmor=runtime/default", "AppArmor"),
326            ("label=type:container_t", "SELinux"),
327            ("seccomp=/profiles/restricted.json", "custom seccomp"),
328            ("systempaths=unconfined", "security option"),
329        ] {
330            let config = BoxConfig {
331                security_opt: vec![option.to_string()],
332                ..Default::default()
333            };
334            let error = resolve_execution(&config).unwrap_err().to_string();
335            assert!(
336                error.contains(expected),
337                "expected {option:?} rejection to mention {expected:?}, got {error:?}"
338            );
339        }
340    }
341
342    #[test]
343    fn microvm_accepts_guest_enforceable_security_options() {
344        let config = BoxConfig {
345            security_opt: vec![
346                " SECCOMP=DEFAULT ".to_string(),
347                "seccomp=unconfined".to_string(),
348                "no-new-privileges".to_string(),
349                "no-new-privileges=false".to_string(),
350            ],
351            cap_add: vec!["NET_ADMIN".to_string()],
352            cap_drop: vec!["NET_RAW".to_string()],
353            privileged: true,
354            ..Default::default()
355        };
356
357        assert!(resolve_execution(&config).is_ok());
358    }
359
360    #[test]
361    fn sandbox_resolves_to_a3s_oci_shared_kernel_with_mandatory_controls() {
362        let plan = resolve_execution(&sandbox_config()).unwrap();
363        assert_eq!(plan.backend, ExecutionBackend::A3sOci);
364        assert!(plan.backend.is_sandbox());
365        assert_eq!(plan.isolation_class, IsolationClass::SharedKernel);
366        for required in SANDBOX_REQUIRED_CONTROLS {
367            assert!(plan.required_controls.iter().any(|value| value == required));
368        }
369    }
370
371    #[test]
372    fn sandbox_rejects_vm_only_features_together() {
373        let config = BoxConfig {
374            isolation: ExecutionIsolation::Sandbox,
375            tee: TeeConfig::Tdx {
376                workload_id: "test".to_string(),
377                simulate: true,
378            },
379            pool: PoolConfig {
380                enabled: true,
381                ..Default::default()
382            },
383            sidecar: Some(SidecarConfig::default()),
384            port_map: vec!["8080:80".to_string()],
385            privileged: true,
386            ..Default::default()
387        };
388
389        let error = resolve_execution(&config).unwrap_err().to_string();
390        assert!(error.contains("TEE and attestation"));
391        assert!(error.contains("warm pools"));
392        assert!(error.contains("vsock sidecars"));
393        assert!(error.contains("published ports"));
394        assert!(error.contains("privileged mode"));
395    }
396
397    #[test]
398    fn sandbox_rejects_unconfined_seccomp() {
399        let config = BoxConfig {
400            security_opt: vec!["seccomp=unconfined".to_string()],
401            ..sandbox_config()
402        };
403        assert!(resolve_execution(&config)
404            .unwrap_err()
405            .to_string()
406            .contains("unconfined seccomp"));
407    }
408
409    #[test]
410    fn sandbox_rejects_security_options_not_wired_to_oci() {
411        for (option, expected) in [
412            ("apparmor=runtime/default", "AppArmor"),
413            ("label=type:container_t", "SELinux"),
414            ("seccomp=/profiles/restricted.json", "custom seccomp"),
415            ("no-new-privileges=false", "requires no-new-privileges"),
416        ] {
417            let config = BoxConfig {
418                security_opt: vec![option.to_string()],
419                ..sandbox_config()
420            };
421            let error = resolve_execution(&config).unwrap_err().to_string();
422            assert!(
423                error.contains(expected),
424                "expected {option:?} rejection to mention {expected:?}, got {error:?}"
425            );
426        }
427    }
428
429    #[test]
430    fn sandbox_accepts_security_options_compiled_by_oci_backend() {
431        let config = BoxConfig {
432            security_opt: vec![
433                "seccomp=default".to_string(),
434                "no-new-privileges".to_string(),
435                "no-new-privileges=true".to_string(),
436            ],
437            cap_add: vec!["cap_chown".to_string()],
438            cap_drop: vec!["NET_RAW".to_string()],
439            ..sandbox_config()
440        };
441
442        assert!(resolve_execution(&config).is_ok());
443    }
444
445    #[test]
446    fn sandbox_normalizes_and_allows_baseline_capabilities() {
447        let config = BoxConfig {
448            cap_add: vec!["cap_chown".to_string(), "NET_BIND_SERVICE".to_string()],
449            ..sandbox_config()
450        };
451        assert!(resolve_execution(&config).is_ok());
452    }
453
454    #[test]
455    fn sandbox_rejects_powerful_added_capability() {
456        let config = BoxConfig {
457            cap_add: vec!["CAP_SYS_ADMIN".to_string()],
458            ..sandbox_config()
459        };
460        let error = resolve_execution(&config).unwrap_err().to_string();
461        assert!(error.contains("SYS_ADMIN"));
462    }
463}