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 = "lowercase")]
12pub enum ExecutionBackend {
13 Krun,
15 Crun,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum IsolationClass {
23 HardwareVm,
25 SharedKernel,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ResolvedExecutionPlan {
32 pub requested_isolation: ExecutionIsolation,
34 pub backend: ExecutionBackend,
36 pub isolation_class: IsolationClass,
38 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
71pub 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
102pub 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
111pub 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}