1use serde::{Deserialize, Serialize};
4
5use crate::config::{BoxConfig, ExecutionIsolation, TeeConfig};
6use crate::error::{BoxError, Result};
7use crate::network::NetworkMode;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "kebab-case")]
12pub enum ExecutionBackend {
13 Krun,
15 A3sOci,
17}
18
19impl ExecutionBackend {
20 pub const fn is_sandbox(self) -> bool {
22 matches!(self, Self::A3sOci)
23 }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "kebab-case")]
29pub enum IsolationClass {
30 HardwareVm,
32 SharedKernel,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ResolvedExecutionPlan {
39 pub requested_isolation: ExecutionIsolation,
41 pub backend: ExecutionBackend,
43 pub isolation_class: IsolationClass,
45 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
78pub 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
109pub 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
118pub 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}