1use std::collections::{HashMap, HashSet};
4use std::path::{Component, Path, PathBuf};
5
6use a3s_box_core::config::BoxConfig;
7use a3s_box_core::error::{BoxError, Result};
8use a3s_box_core::guest_exec::RUNTIME_EXEC_CONFIG_PATH;
9use a3s_box_core::rootfs_metadata::RUNTIME_ENV_PATH;
10use a3s_oci_sdk::{
11 CONTROL_CGROUP_CPU_HEADROOM_ANNOTATION, CONTROL_CGROUP_MEMORY_HEADROOM_ANNOTATION,
12 CONTROL_CGROUP_PIDS_HEADROOM_ANNOTATION, CONTROL_WORKLOAD_CGROUP_LAYOUT_ANNOTATION,
13 CONTROL_WORKLOAD_CGROUP_LAYOUT_V1, PORTABLE_ROOTFS_METADATA_ANNOTATION,
14 PORTABLE_ROOTFS_METADATA_SCHEMA_V1, RUNTIME_BUNDLE_HANDOFF_EXTENSION,
15 RUNTIME_BUNDLE_HANDOFF_MOVE_V1,
16};
17use oci_spec::runtime::{
18 Arch, Capabilities, Capability, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxCpuBuilder,
19 LinuxDeviceBuilder, LinuxDeviceCgroupBuilder, LinuxDeviceType, LinuxIdMappingBuilder,
20 LinuxMemoryBuilder, LinuxNamespaceBuilder, LinuxNamespaceType, LinuxPidsBuilder,
21 LinuxResourcesBuilder, LinuxSeccompAction, LinuxSeccompArgBuilder, LinuxSeccompBuilder,
22 LinuxSeccompOperator, LinuxSyscallBuilder, Mount, MountBuilder, ProcessBuilder, RootBuilder,
23 Spec, SpecBuilder, UserBuilder,
24};
25
26use super::capability::{validate_id_mapping_plan, IdMapping, SandboxIdMappingPlan};
27
28pub const SANDBOX_BUNDLE_SCHEMA: &str = "a3s.box.sandbox-bundle.v1";
30pub const PORTABLE_MICROVM_BUNDLE_SCHEMA: &str = "a3s.box.portable-microvm-bundle.v1";
32pub const DEFAULT_SANDBOX_PIDS_LIMIT: i64 = 4096;
34const DEFAULT_CPU_PERIOD_US: u64 = 100_000;
35const DEFAULT_TMPFS_SIZE: &str = "67108864";
36const SANDBOX_CONTROL_MEMORY_HEADROOM_BYTES: i64 = 128 * 1024 * 1024;
37const SANDBOX_CONTROL_PIDS_HEADROOM: i64 = 128;
38const SBIN_INIT: &str = "/sbin/init";
39const USR_SBIN_INIT: &str = "/usr/sbin/init";
40const LINUX_EPERM: u32 = 1;
41const LINUX_ENOSYS: u32 = 38;
42const LINUX_CLONE_NAMESPACE_MASK: u64 =
43 0x0002_0000 | 0x0200_0000 | 0x0400_0000 | 0x0800_0000 | 0x1000_0000 | 0x2000_0000 | 0x4000_0000;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SandboxMount {
48 pub source: PathBuf,
49 pub destination: PathBuf,
50 pub read_only: bool,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SandboxTmpfs {
56 pub destination: PathBuf,
57 pub size_bytes: u64,
58 pub read_only: bool,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct SandboxResources {
64 pub memory_limit: i64,
65 pub memory_reservation: Option<i64>,
66 pub memory_swap: Option<i64>,
67 pub cpu_shares: Option<u64>,
68 pub cpu_quota: i64,
69 pub cpu_period: u64,
70 pub cpuset_cpus: Option<String>,
71 pub pids_limit: i64,
72}
73
74impl SandboxResources {
75 pub fn from_box_config(config: &BoxConfig) -> Result<Self> {
77 if config.resources.memory_mb == 0 {
78 return Err(BoxError::ConfigError(
79 "Sandbox memory limit must be greater than zero".to_string(),
80 ));
81 }
82 if config.resources.vcpus == 0 {
83 return Err(BoxError::ConfigError(
84 "Sandbox CPU limit must be greater than zero".to_string(),
85 ));
86 }
87
88 let memory_limit = match config.resource_limits.sandbox_memory_limit_bytes {
89 Some(0) => {
90 return Err(BoxError::ConfigError(
91 "Sandbox memory limit must be greater than zero".to_string(),
92 ))
93 }
94 Some(bytes) => i64::try_from(bytes).map_err(|_| {
95 BoxError::ConfigError("Sandbox memory limit overflows i64".to_string())
96 })?,
97 None => i64::from(config.resources.memory_mb)
98 .checked_mul(1024 * 1024)
99 .ok_or_else(|| {
100 BoxError::ConfigError("Sandbox memory limit overflows i64".to_string())
101 })?,
102 };
103 let memory_reservation = config
104 .resource_limits
105 .memory_reservation
106 .map(|value| {
107 i64::try_from(value).map_err(|_| {
108 BoxError::ConfigError("Sandbox memory reservation overflows i64".to_string())
109 })
110 })
111 .transpose()?;
112 if memory_reservation.is_some_and(|reservation| reservation > memory_limit) {
113 return Err(BoxError::ConfigError(
114 "Sandbox memory reservation cannot exceed the hard memory limit".to_string(),
115 ));
116 }
117 let memory_swap = config.resource_limits.memory_swap;
118 if memory_swap.is_some_and(|swap| swap != -1 && swap < memory_limit) {
119 return Err(BoxError::ConfigError(
120 "Sandbox memory+swap limit cannot be below the hard memory limit".to_string(),
121 ));
122 }
123
124 let cpu_period = config
125 .resource_limits
126 .cpu_period
127 .unwrap_or(DEFAULT_CPU_PERIOD_US);
128 if cpu_period == 0 {
129 return Err(BoxError::ConfigError(
130 "Sandbox CPU period must be greater than zero".to_string(),
131 ));
132 }
133 let cpu_quota = match config.resource_limits.cpu_quota {
134 Some(quota) if quota > 0 => quota,
135 Some(_) => {
136 return Err(BoxError::ConfigError(
137 "Sandbox CPU quota must be greater than zero".to_string(),
138 ))
139 }
140 None => i64::from(config.resources.vcpus)
141 .checked_mul(i64::try_from(cpu_period).map_err(|_| {
142 BoxError::ConfigError("Sandbox CPU period overflows i64".to_string())
143 })?)
144 .ok_or_else(|| {
145 BoxError::ConfigError("Sandbox CPU quota overflows i64".to_string())
146 })?,
147 };
148
149 if config
150 .resource_limits
151 .cpu_shares
152 .is_some_and(|shares| !(2..=262_144).contains(&shares))
153 {
154 return Err(BoxError::ConfigError(
155 "Sandbox CPU shares must be between 2 and 262144".to_string(),
156 ));
157 }
158 if let Some(cpuset) = config.resource_limits.cpuset_cpus.as_deref() {
159 validate_cpuset(cpuset)?;
160 }
161
162 let pids_limit_u64 = config
163 .resource_limits
164 .pids_limit
165 .unwrap_or(DEFAULT_SANDBOX_PIDS_LIMIT as u64);
166 let pids_limit = i64::try_from(pids_limit_u64)
167 .map_err(|_| BoxError::ConfigError("Sandbox PID limit overflows i64".to_string()))?;
168 if pids_limit <= 0 {
169 return Err(BoxError::ConfigError(
170 "Sandbox PID limit must be greater than zero".to_string(),
171 ));
172 }
173
174 Ok(Self {
175 memory_limit,
176 memory_reservation,
177 memory_swap,
178 cpu_shares: config.resource_limits.cpu_shares,
179 cpu_quota,
180 cpu_period,
181 cpuset_cpus: config.resource_limits.cpuset_cpus.clone(),
182 pids_limit,
183 })
184 }
185}
186
187#[derive(Debug, Clone)]
189pub struct SandboxBundleSpec {
190 pub box_id: String,
191 pub rootfs_path: PathBuf,
192 pub rootfs_read_only: bool,
193 pub hostname: String,
194 pub init_path: String,
196 pub init_environment: Vec<(String, String)>,
197 pub mounts: Vec<SandboxMount>,
198 pub tmpfs: Vec<SandboxTmpfs>,
199 pub id_mappings: SandboxIdMappingPlan,
200 pub resources: SandboxResources,
201 pub requested_capabilities: Vec<String>,
202 pub execution_plan_digest: String,
203 pub runtime_digest: String,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct SandboxRuntimeProcess {
213 pub args: Vec<String>,
214 pub environment: Vec<(String, String)>,
215 pub cwd: PathBuf,
216 pub uid: u32,
217 pub gid: u32,
218 pub additional_gids: Vec<u32>,
219 pub dropped_capabilities: Vec<String>,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223enum SandboxProcessOwner {
224 GuestInit,
225 RuntimeOwned,
226}
227
228impl SandboxProcessOwner {
229 const fn annotation(self) -> &'static str {
230 match self {
231 Self::GuestInit => "guest-init",
232 Self::RuntimeOwned => "a3s-oci-runtime",
233 }
234 }
235
236 const fn uses_control_workload_cgroup(self) -> bool {
237 matches!(self, Self::GuestInit)
238 }
239}
240
241pub fn compile_oci_spec(input: &SandboxBundleSpec) -> Result<Spec> {
244 validate_bundle_input(input)?;
245 validate_init_path(&input.init_path)?;
246
247 let process = ProcessBuilder::default()
248 .terminal(false)
249 .user(
250 UserBuilder::default()
251 .uid(0u32)
252 .gid(0u32)
253 .build()
254 .map_err(oci_error)?,
255 )
256 .args(vec![input.init_path.clone()])
257 .env(compile_environment(&input.init_environment)?)
258 .cwd(PathBuf::from("/"))
259 .capabilities(compile_capabilities(&input.requested_capabilities)?)
260 .no_new_privileges(true)
261 .build()
262 .map_err(oci_error)?;
263
264 compile_spec(input, process, SandboxProcessOwner::GuestInit)
265}
266
267pub fn compile_runtime_owned_oci_spec(
271 input: &SandboxBundleSpec,
272 runtime_process: &SandboxRuntimeProcess,
273) -> Result<Spec> {
274 validate_bundle_input(input)?;
275 validate_runtime_process(runtime_process, &input.id_mappings)?;
276
277 let user = UserBuilder::default()
278 .uid(runtime_process.uid)
279 .gid(runtime_process.gid)
280 .additional_gids(runtime_process.additional_gids.clone())
281 .build()
282 .map_err(oci_error)?;
283 let process = ProcessBuilder::default()
284 .terminal(false)
285 .user(user)
286 .args(runtime_process.args.clone())
287 .env(compile_runtime_environment(&runtime_process.environment)?)
288 .cwd(runtime_process.cwd.clone())
289 .capabilities(compile_workload_capabilities(
290 &input.requested_capabilities,
291 &runtime_process.dropped_capabilities,
292 )?)
293 .no_new_privileges(true)
294 .build()
295 .map_err(oci_error)?;
296
297 compile_spec(input, process, SandboxProcessOwner::RuntimeOwned)
298}
299
300pub fn compile_portable_microvm_oci_spec(
302 box_id: &str,
303 hostname: &str,
304 runtime_process: &SandboxRuntimeProcess,
305) -> Result<Spec> {
306 validate_box_id(box_id)?;
307 validate_hostname(hostname)?;
308 validate_runtime_process_shape(runtime_process)?;
309
310 let user = UserBuilder::default()
311 .uid(runtime_process.uid)
312 .gid(runtime_process.gid)
313 .additional_gids(runtime_process.additional_gids.clone())
314 .build()
315 .map_err(oci_error)?;
316 let process = ProcessBuilder::default()
317 .terminal(false)
318 .user(user)
319 .args(runtime_process.args.clone())
320 .env(compile_runtime_environment(&runtime_process.environment)?)
321 .cwd(runtime_process.cwd.clone())
322 .capabilities(compile_workload_capabilities(
323 &[],
324 &runtime_process.dropped_capabilities,
325 )?)
326 .no_new_privileges(true)
327 .build()
328 .map_err(oci_error)?;
329 let linux = LinuxBuilder::default()
330 .namespaces(compile_portable_microvm_namespaces()?)
331 .cgroups_path(PathBuf::from(format!("a3s-box/{box_id}")))
332 .build()
333 .map_err(oci_error)?;
334 let mut annotations = HashMap::new();
335 annotations.insert(
336 "com.a3s.box.microvm.schema".to_string(),
337 PORTABLE_MICROVM_BUNDLE_SCHEMA.to_string(),
338 );
339 annotations.insert(
340 "com.a3s.box.isolation-class".to_string(),
341 "hardware-vm".to_string(),
342 );
343 annotations.insert(
344 "com.a3s.box.process-owner".to_string(),
345 "a3s-oci-runtime".to_string(),
346 );
347 annotations.insert(
348 RUNTIME_BUNDLE_HANDOFF_EXTENSION.to_string(),
349 RUNTIME_BUNDLE_HANDOFF_MOVE_V1.to_string(),
350 );
351 annotations.insert(
352 PORTABLE_ROOTFS_METADATA_ANNOTATION.to_string(),
353 PORTABLE_ROOTFS_METADATA_SCHEMA_V1.to_string(),
354 );
355
356 SpecBuilder::default()
357 .version("1.3.0".to_string())
358 .root(
359 RootBuilder::default()
360 .path(PathBuf::from("rootfs"))
361 .readonly(false)
362 .build()
363 .map_err(oci_error)?,
364 )
365 .mounts(vec![mount(
366 "/proc",
367 "proc",
368 "proc",
369 &["nosuid", "noexec", "nodev"],
370 )?])
371 .process(process)
372 .hostname(hostname.to_string())
373 .annotations(annotations)
374 .linux(linux)
375 .build()
376 .map_err(oci_error)
377}
378
379fn validate_bundle_input(input: &SandboxBundleSpec) -> Result<()> {
380 validate_box_id(&input.box_id)?;
381 validate_rootfs_path(&input.rootfs_path)?;
382 validate_hostname(&input.hostname)?;
383 validate_digest("execution plan", &input.execution_plan_digest)?;
384 validate_digest("runtime", &input.runtime_digest)?;
385 validate_id_mapping_plan(&input.id_mappings)
386}
387
388fn compile_spec(
389 input: &SandboxBundleSpec,
390 process: oci_spec::runtime::Process,
391 owner: SandboxProcessOwner,
392) -> Result<Spec> {
393 let linux = LinuxBuilder::default()
394 .uid_mappings(compile_id_mappings(&input.id_mappings.uid_mappings)?)
395 .gid_mappings(compile_id_mappings(&input.id_mappings.gid_mappings)?)
396 .namespaces(compile_namespaces()?)
397 .resources(compile_resources(&input.resources)?)
398 .cgroups_path(PathBuf::from(format!("a3s-box/{}", input.box_id)))
399 .devices(compile_devices()?)
400 .seccomp(compile_seccomp()?)
401 .rootfs_propagation("private".to_string())
402 .masked_paths(masked_paths())
403 .readonly_paths(readonly_paths())
404 .build()
405 .map_err(oci_error)?;
406
407 let mut annotations = HashMap::new();
408 annotations.insert(
409 "com.a3s.box.sandbox.schema".to_string(),
410 SANDBOX_BUNDLE_SCHEMA.to_string(),
411 );
412 annotations.insert(
413 "com.a3s.box.execution-plan.digest".to_string(),
414 input.execution_plan_digest.clone(),
415 );
416 annotations.insert(
417 "com.a3s.box.runtime.digest".to_string(),
418 input.runtime_digest.clone(),
419 );
420 annotations.insert(
421 "com.a3s.box.isolation-class".to_string(),
422 "shared-kernel".to_string(),
423 );
424 annotations.insert(
425 "com.a3s.box.process-owner".to_string(),
426 owner.annotation().to_string(),
427 );
428 if owner.uses_control_workload_cgroup() {
429 annotations.insert(
430 CONTROL_WORKLOAD_CGROUP_LAYOUT_ANNOTATION.to_string(),
431 CONTROL_WORKLOAD_CGROUP_LAYOUT_V1.to_string(),
432 );
433 annotations.insert(
434 CONTROL_CGROUP_MEMORY_HEADROOM_ANNOTATION.to_string(),
435 SANDBOX_CONTROL_MEMORY_HEADROOM_BYTES.to_string(),
436 );
437 annotations.insert(
438 CONTROL_CGROUP_CPU_HEADROOM_ANNOTATION.to_string(),
439 input.resources.cpu_period.to_string(),
440 );
441 annotations.insert(
442 CONTROL_CGROUP_PIDS_HEADROOM_ANNOTATION.to_string(),
443 SANDBOX_CONTROL_PIDS_HEADROOM.to_string(),
444 );
445 }
446
447 SpecBuilder::default()
448 .version("1.1.0".to_string())
449 .root(
450 RootBuilder::default()
451 .path(input.rootfs_path.clone())
452 .readonly(input.rootfs_read_only)
453 .build()
454 .map_err(oci_error)?,
455 )
456 .mounts(compile_mounts(&input.mounts, &input.tmpfs)?)
457 .process(process)
458 .hostname(input.hostname.clone())
459 .annotations(annotations)
460 .linux(linux)
461 .build()
462 .map_err(oci_error)
463}
464
465const RESERVED_BOOTSTRAP_ENVIRONMENT: &[&str] = &[
466 "A3S_BOOTSTRAP_MODE",
467 "A3S_EXEC_LISTENER_FD",
468 "A3S_PTY_LISTENER_FD",
469 "A3S_INIT_LOG_FD",
470];
471
472fn validated_environment(
473 environment: &[(String, String)],
474) -> Result<std::collections::BTreeMap<String, String>> {
475 let mut values = std::collections::BTreeMap::new();
476 for (key, value) in environment {
477 if key.is_empty() || key.contains(['=', '\0']) || value.contains('\0') {
478 return Err(BoxError::ConfigError(format!(
479 "Invalid Sandbox process environment key {key:?}"
480 )));
481 }
482 values.insert(key.clone(), value.clone());
483 }
484 values.entry("PATH".to_string()).or_insert_with(|| {
485 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string()
486 });
487 Ok(values)
488}
489
490fn compile_environment(environment: &[(String, String)]) -> Result<Vec<String>> {
491 let mut values = validated_environment(environment)?;
492 values.insert("A3S_BOOTSTRAP_MODE".to_string(), "host-sandbox".to_string());
493 values.insert("A3S_EXEC_LISTENER_FD".to_string(), "3".to_string());
494 values.insert("A3S_PTY_LISTENER_FD".to_string(), "4".to_string());
495 values.insert("A3S_INIT_LOG_FD".to_string(), "5".to_string());
496
497 Ok(values
498 .into_iter()
499 .map(|(key, value)| format!("{key}={value}"))
500 .collect())
501}
502
503fn compile_runtime_environment(environment: &[(String, String)]) -> Result<Vec<String>> {
504 let mut values = validated_environment(environment)?;
505 for reserved in RESERVED_BOOTSTRAP_ENVIRONMENT {
506 values.remove(*reserved);
507 }
508 Ok(values
509 .into_iter()
510 .map(|(key, value)| format!("{key}={value}"))
511 .collect())
512}
513
514fn validate_runtime_process(
515 process: &SandboxRuntimeProcess,
516 id_mappings: &SandboxIdMappingPlan,
517) -> Result<()> {
518 validate_runtime_process_shape(process)?;
519 if process.uid > id_mappings.maximum_container_uid {
520 return Err(BoxError::ConfigError(format!(
521 "Sandbox process UID {} exceeds the mapped maximum {}",
522 process.uid, id_mappings.maximum_container_uid
523 )));
524 }
525 for gid in std::iter::once(&process.gid).chain(process.additional_gids.iter()) {
526 if *gid > id_mappings.maximum_container_gid {
527 return Err(BoxError::ConfigError(format!(
528 "Sandbox process GID {gid} exceeds the mapped maximum {}",
529 id_mappings.maximum_container_gid
530 )));
531 }
532 }
533 Ok(())
534}
535
536fn validate_runtime_process_shape(process: &SandboxRuntimeProcess) -> Result<()> {
537 let executable = process.args.first().ok_or_else(|| {
538 BoxError::ConfigError("Sandbox runtime-owned process requires an executable".to_string())
539 })?;
540 validate_linux_absolute_normalized(Path::new(executable), "process executable")?;
541 if process.args.iter().any(|argument| argument.contains('\0')) {
542 return Err(BoxError::ConfigError(
543 "Sandbox process arguments must not contain NUL".to_string(),
544 ));
545 }
546 validate_linux_absolute_normalized(&process.cwd, "process working directory")?;
547 Ok(())
548}
549
550fn compile_capabilities(requested: &[String]) -> Result<oci_spec::runtime::LinuxCapabilities> {
551 let mut bounding: Capabilities = [
555 Capability::Chown,
556 Capability::DacOverride,
557 Capability::Fowner,
558 Capability::Fsetid,
559 Capability::Kill,
560 Capability::NetAdmin,
561 Capability::NetBindService,
562 Capability::Setgid,
563 Capability::Setpcap,
564 Capability::Setuid,
565 Capability::SysChroot,
566 ]
567 .into_iter()
568 .collect();
569
570 for capability in requested {
571 bounding.insert(parse_allowed_capability(capability)?);
572 }
573
574 LinuxCapabilitiesBuilder::default()
575 .bounding(bounding.clone())
576 .effective(bounding.clone())
577 .permitted(bounding)
578 .inheritable(HashSet::<Capability>::new())
579 .ambient(HashSet::<Capability>::new())
580 .build()
581 .map_err(oci_error)
582}
583
584fn compile_workload_capabilities(
585 added: &[String],
586 dropped: &[String],
587) -> Result<oci_spec::runtime::LinuxCapabilities> {
588 let mut names: std::collections::BTreeSet<String> = [
589 "CHOWN",
590 "DAC_OVERRIDE",
591 "FOWNER",
592 "FSETID",
593 "KILL",
594 "NET_BIND_SERVICE",
595 "SETGID",
596 "SETPCAP",
597 "SETUID",
598 "SYS_CHROOT",
599 ]
600 .into_iter()
601 .map(ToString::to_string)
602 .collect();
603
604 for capability in added {
605 let normalized = normalize_capability_name(capability);
606 parse_allowed_capability(&normalized)?;
607 names.insert(normalized);
608 }
609 if dropped
610 .iter()
611 .any(|capability| normalize_capability_name(capability) == "ALL")
612 {
613 names.clear();
614 } else {
615 for capability in dropped {
616 let normalized = normalize_capability_name(capability);
617 parse_allowed_capability(&normalized)?;
618 names.remove(&normalized);
619 }
620 }
621
622 let capabilities = names
623 .iter()
624 .map(|capability| parse_allowed_capability(capability))
625 .collect::<Result<Capabilities>>()?;
626 LinuxCapabilitiesBuilder::default()
627 .bounding(capabilities.clone())
628 .effective(capabilities.clone())
629 .permitted(capabilities)
630 .inheritable(HashSet::<Capability>::new())
631 .ambient(HashSet::<Capability>::new())
632 .build()
633 .map_err(oci_error)
634}
635
636fn normalize_capability_name(value: &str) -> String {
637 let normalized = value.trim().to_ascii_uppercase();
638 normalized
639 .strip_prefix("CAP_")
640 .unwrap_or(&normalized)
641 .to_string()
642}
643
644fn parse_allowed_capability(value: &str) -> Result<Capability> {
645 let normalized = normalize_capability_name(value);
646 let capability = match normalized.as_str() {
647 "AUDIT_WRITE" => Capability::AuditWrite,
648 "CHOWN" => Capability::Chown,
649 "DAC_OVERRIDE" => Capability::DacOverride,
650 "FOWNER" => Capability::Fowner,
651 "FSETID" => Capability::Fsetid,
652 "KILL" => Capability::Kill,
653 "MKNOD" => Capability::Mknod,
654 "NET_BIND_SERVICE" => Capability::NetBindService,
655 "SETFCAP" => Capability::Setfcap,
656 "SETGID" => Capability::Setgid,
657 "SETPCAP" => Capability::Setpcap,
658 "SETUID" => Capability::Setuid,
659 "SYS_CHROOT" => Capability::SysChroot,
660 _ => {
661 return Err(BoxError::ConfigError(format!(
662 "Sandbox capability {value:?} is outside the allowlist"
663 )))
664 }
665 };
666 Ok(capability)
667}
668
669fn compile_id_mappings(mappings: &[IdMapping]) -> Result<Vec<oci_spec::runtime::LinuxIdMapping>> {
670 mappings
671 .iter()
672 .map(|mapping| {
673 LinuxIdMappingBuilder::default()
674 .container_id(mapping.container_id)
675 .host_id(mapping.host_id)
676 .size(mapping.size)
677 .build()
678 .map_err(oci_error)
679 })
680 .collect()
681}
682
683fn compile_namespaces() -> Result<Vec<oci_spec::runtime::LinuxNamespace>> {
684 [
685 LinuxNamespaceType::User,
686 LinuxNamespaceType::Mount,
687 LinuxNamespaceType::Pid,
688 LinuxNamespaceType::Ipc,
689 LinuxNamespaceType::Uts,
690 LinuxNamespaceType::Network,
691 LinuxNamespaceType::Cgroup,
692 ]
693 .into_iter()
694 .map(|typ| {
695 LinuxNamespaceBuilder::default()
696 .typ(typ)
697 .build()
698 .map_err(oci_error)
699 })
700 .collect()
701}
702
703fn compile_portable_microvm_namespaces() -> Result<Vec<oci_spec::runtime::LinuxNamespace>> {
704 [
705 LinuxNamespaceType::Uts,
706 LinuxNamespaceType::Mount,
707 LinuxNamespaceType::Ipc,
708 LinuxNamespaceType::Network,
709 LinuxNamespaceType::Cgroup,
710 LinuxNamespaceType::Pid,
711 ]
712 .into_iter()
713 .map(|typ| {
714 LinuxNamespaceBuilder::default()
715 .typ(typ)
716 .build()
717 .map_err(oci_error)
718 })
719 .collect()
720}
721
722pub(crate) fn compile_resources(
723 resources: &SandboxResources,
724) -> Result<oci_spec::runtime::LinuxResources> {
725 let mut memory = LinuxMemoryBuilder::default().limit(resources.memory_limit);
729 if let Some(reservation) = resources.memory_reservation {
730 memory = memory.reservation(reservation);
731 }
732 if let Some(swap) = resources.memory_swap {
733 memory = memory.swap(swap);
734 }
735
736 let mut cpu = LinuxCpuBuilder::default()
737 .quota(resources.cpu_quota)
738 .period(resources.cpu_period);
739 if let Some(shares) = resources.cpu_shares {
740 cpu = cpu.shares(shares);
741 }
742 if let Some(cpuset) = resources.cpuset_cpus.as_ref() {
743 cpu = cpu.cpus(cpuset.clone());
744 }
745
746 let mut device_rules = vec![LinuxDeviceCgroupBuilder::default()
747 .allow(false)
748 .access("rwm".to_string())
749 .build()
750 .map_err(oci_error)?];
751 for device in minimal_device_numbers() {
752 device_rules.push(
753 LinuxDeviceCgroupBuilder::default()
754 .allow(true)
755 .typ(LinuxDeviceType::C)
756 .major(device.1)
757 .minor(device.2)
758 .access("rwm".to_string())
759 .build()
760 .map_err(oci_error)?,
761 );
762 }
763
764 LinuxResourcesBuilder::default()
765 .devices(device_rules)
766 .memory(memory.build().map_err(oci_error)?)
767 .cpu(cpu.build().map_err(oci_error)?)
768 .pids(
769 LinuxPidsBuilder::default()
770 .limit(resources.pids_limit)
771 .build()
772 .map_err(oci_error)?,
773 )
774 .build()
775 .map_err(oci_error)
776}
777
778fn compile_devices() -> Result<Vec<oci_spec::runtime::LinuxDevice>> {
779 minimal_device_numbers()
780 .iter()
781 .map(|(path, major, minor)| {
782 LinuxDeviceBuilder::default()
783 .path(PathBuf::from(path))
784 .typ(LinuxDeviceType::C)
785 .major(*major)
786 .minor(*minor)
787 .file_mode(0o666u32)
788 .uid(0u32)
789 .gid(0u32)
790 .build()
791 .map_err(oci_error)
792 })
793 .collect()
794}
795
796fn minimal_device_numbers() -> &'static [(&'static str, i64, i64)] {
797 &[
798 ("/dev/null", 1, 3),
799 ("/dev/zero", 1, 5),
800 ("/dev/full", 1, 7),
801 ("/dev/random", 1, 8),
802 ("/dev/urandom", 1, 9),
803 ("/dev/tty", 5, 0),
804 ]
805}
806
807fn compile_mounts(user_mounts: &[SandboxMount], user_tmpfs: &[SandboxTmpfs]) -> Result<Vec<Mount>> {
808 let mut mounts = vec![
809 mount("/proc", "proc", "proc", &["nosuid", "noexec", "nodev"])?,
810 mount(
811 "/dev",
812 "tmpfs",
813 "tmpfs",
814 &[
815 "nosuid",
816 "strictatime",
817 "mode=755",
818 &format!("size={DEFAULT_TMPFS_SIZE}"),
819 ],
820 )?,
821 mount(
822 "/dev/pts",
823 "devpts",
824 "devpts",
825 &[
826 "nosuid",
827 "noexec",
828 "newinstance",
829 "ptmxmode=0666",
830 "mode=0620",
831 "gid=5",
832 ],
833 )?,
834 mount(
835 "/dev/shm",
836 "tmpfs",
837 "shm",
838 &[
839 "nosuid",
840 "noexec",
841 "nodev",
842 "mode=1777",
843 &format!("size={DEFAULT_TMPFS_SIZE}"),
844 ],
845 )?,
846 mount(
847 "/dev/mqueue",
848 "mqueue",
849 "mqueue",
850 &["nosuid", "noexec", "nodev"],
851 )?,
852 mount(
853 "/sys",
854 "sysfs",
855 "sysfs",
856 &["nosuid", "noexec", "nodev", "ro"],
857 )?,
858 mount(
859 "/sys/fs/cgroup",
860 "cgroup",
861 "cgroup",
862 &["nosuid", "noexec", "nodev", "relatime", "ro"],
866 )?,
867 mount(
868 "/tmp",
869 "tmpfs",
870 "tmpfs",
871 &[
872 "nosuid",
873 "nodev",
874 "mode=1777",
875 &format!("size={DEFAULT_TMPFS_SIZE}"),
876 ],
877 )?,
878 mount(
879 "/run",
880 "tmpfs",
881 "tmpfs",
882 &[
883 "nosuid",
884 "nodev",
885 "mode=755",
886 &format!("size={DEFAULT_TMPFS_SIZE}"),
887 ],
888 )?,
889 ];
890
891 let mut destinations: HashSet<PathBuf> = mounts
892 .iter()
893 .map(|entry| entry.destination().clone())
894 .collect();
895 for user_mount in user_mounts {
896 validate_user_mount(user_mount)?;
897 if !destinations.insert(user_mount.destination.clone()) {
898 return Err(BoxError::ConfigError(format!(
899 "Duplicate Sandbox mount destination {}",
900 user_mount.destination.display()
901 )));
902 }
903 let mut options = vec![
904 "rbind".to_string(),
905 "rprivate".to_string(),
906 "nosuid".to_string(),
907 "nodev".to_string(),
908 ];
909 options.push(if user_mount.read_only { "ro" } else { "rw" }.to_string());
910 mounts.push(
911 MountBuilder::default()
912 .destination(user_mount.destination.clone())
913 .typ("bind".to_string())
914 .source(user_mount.source.clone())
915 .options(options)
916 .build()
917 .map_err(oci_error)?,
918 );
919 }
920
921 for tmpfs in user_tmpfs {
922 validate_linux_absolute_normalized(&tmpfs.destination, "tmpfs destination")?;
923 if tmpfs.size_bytes == 0 {
924 return Err(BoxError::ConfigError(format!(
925 "Sandbox tmpfs {} must have a non-zero size",
926 tmpfs.destination.display()
927 )));
928 }
929 let is_shared_memory = tmpfs.destination == Path::new("/dev/shm");
930 if linux_path_is_or_below(&tmpfs.destination, Path::new("/proc"))
931 || linux_path_is_or_below(&tmpfs.destination, Path::new("/sys"))
932 || (linux_path_is_or_below(&tmpfs.destination, Path::new("/dev")) && !is_shared_memory)
933 || linux_path_is_or_below(&tmpfs.destination, Path::new("/run/a3s-box"))
934 || tmpfs.destination == Path::new("/")
935 {
936 return Err(BoxError::ConfigError(format!(
937 "Sandbox tmpfs destination {} is protected",
938 tmpfs.destination.display()
939 )));
940 }
941 if let Some(index) = mounts
944 .iter()
945 .position(|mount| mount.destination() == &tmpfs.destination)
946 {
947 if !matches!(
948 tmpfs.destination.to_str(),
949 Some("/tmp" | "/run" | "/dev/shm")
950 ) {
951 return Err(BoxError::ConfigError(format!(
952 "Duplicate Sandbox mount destination {}",
953 tmpfs.destination.display()
954 )));
955 }
956 mounts.remove(index);
957 destinations.remove(&tmpfs.destination);
958 }
959 if !destinations.insert(tmpfs.destination.clone()) {
960 return Err(BoxError::ConfigError(format!(
961 "Duplicate Sandbox mount destination {}",
962 tmpfs.destination.display()
963 )));
964 }
965 let mut options = vec![
966 "nosuid".to_string(),
967 "nodev".to_string(),
968 "mode=1777".to_string(),
969 format!("size={}", tmpfs.size_bytes),
970 if tmpfs.read_only { "ro" } else { "rw" }.to_string(),
971 ];
972 if is_shared_memory {
973 options.push("noexec".to_string());
974 }
975 mounts.push(
976 MountBuilder::default()
977 .destination(tmpfs.destination.clone())
978 .typ("tmpfs".to_string())
979 .source(PathBuf::from("tmpfs"))
980 .options(options)
981 .build()
982 .map_err(oci_error)?,
983 );
984 }
985
986 Ok(mounts)
987}
988
989fn mount(destination: &str, typ: &str, source: &str, options: &[&str]) -> Result<Mount> {
990 MountBuilder::default()
991 .destination(PathBuf::from(destination))
992 .typ(typ.to_string())
993 .source(PathBuf::from(source))
994 .options(
995 options
996 .iter()
997 .map(|value| (*value).to_string())
998 .collect::<Vec<_>>(),
999 )
1000 .build()
1001 .map_err(oci_error)
1002}
1003
1004fn compile_seccomp() -> Result<oci_spec::runtime::LinuxSeccomp> {
1005 let allowed = LinuxSyscallBuilder::default()
1006 .names(
1007 ALLOWED_SYSCALLS
1008 .iter()
1009 .map(|name| (*name).to_string())
1010 .collect::<Vec<_>>(),
1011 )
1012 .action(LinuxSeccompAction::ScmpActAllow)
1013 .build()
1014 .map_err(oci_error)?;
1015
1016 let clone = LinuxSyscallBuilder::default()
1019 .names(vec!["clone".to_string()])
1020 .action(LinuxSeccompAction::ScmpActAllow)
1021 .args(vec![LinuxSeccompArgBuilder::default()
1022 .index(0usize)
1023 .value(0u64)
1024 .value_two(LINUX_CLONE_NAMESPACE_MASK)
1025 .op(LinuxSeccompOperator::ScmpCmpMaskedEq)
1026 .build()
1027 .map_err(oci_error)?])
1028 .build()
1029 .map_err(oci_error)?;
1030 let clone3 = LinuxSyscallBuilder::default()
1031 .names(vec!["clone3".to_string()])
1032 .action(LinuxSeccompAction::ScmpActErrno)
1033 .errno_ret(LINUX_ENOSYS)
1034 .build()
1035 .map_err(oci_error)?;
1036
1037 LinuxSeccompBuilder::default()
1038 .default_action(LinuxSeccompAction::ScmpActErrno)
1039 .default_errno_ret(LINUX_EPERM)
1040 .architectures(vec![certified_seccomp_architecture()?])
1041 .syscalls(vec![allowed, clone, clone3])
1042 .build()
1043 .map_err(oci_error)
1044}
1045
1046fn certified_seccomp_architecture() -> Result<Arch> {
1047 match std::env::consts::ARCH {
1048 "x86_64" => Ok(Arch::ScmpArchX86_64),
1049 "aarch64" => Ok(Arch::ScmpArchAarch64),
1050 architecture => Err(BoxError::ConfigError(format!(
1051 "Sandbox seccomp is not certified for architecture {architecture}"
1052 ))),
1053 }
1054}
1055
1056fn validate_user_mount(mount: &SandboxMount) -> Result<()> {
1057 validate_host_absolute_normalized(&mount.source, "mount source")?;
1058 validate_linux_absolute_normalized(&mount.destination, "mount destination")?;
1059
1060 const PROTECTED_SOURCES: &[&str] = &[
1061 "/", "/boot", "/dev", "/etc", "/proc", "/run", "/sys", "/var/run",
1062 ];
1063 if PROTECTED_SOURCES
1064 .iter()
1065 .any(|protected| host_path_is_or_below(&mount.source, Path::new(protected)))
1066 {
1067 return Err(BoxError::ConfigError(format!(
1068 "Sandbox mount source {} is protected",
1069 mount.source.display()
1070 )));
1071 }
1072
1073 const PROTECTED_DESTINATIONS: &[&str] = &[
1074 "/dev",
1075 "/proc",
1076 "/run/a3s-box",
1077 SBIN_INIT,
1078 USR_SBIN_INIT,
1079 "/sys",
1080 RUNTIME_EXEC_CONFIG_PATH,
1081 RUNTIME_ENV_PATH,
1082 "/.a3s_image_metadata_v1.json",
1083 "/.a3s_image_metadata_v1.json.tmp",
1084 "/.a3s_rootfs_metadata_v1.json",
1085 "/.a3s_rootfs_metadata_v1.json.tmp",
1086 "/.a3s_rootfs_metadata_v1.previous.json",
1087 ];
1088 if mount.destination == Path::new("/")
1089 || PROTECTED_DESTINATIONS.iter().any(|protected| {
1090 let protected = Path::new(protected);
1091 linux_path_is_or_below(&mount.destination, protected)
1092 || (mount.destination != Path::new("/")
1093 && linux_path_is_or_below(protected, &mount.destination))
1094 })
1095 {
1096 return Err(BoxError::ConfigError(format!(
1097 "Sandbox mount destination {} is protected",
1098 mount.destination.display()
1099 )));
1100 }
1101 Ok(())
1102}
1103
1104fn validate_rootfs_path(path: &Path) -> Result<()> {
1105 validate_host_absolute_normalized(path, "rootfs path")?;
1106 if path.parent().is_none() {
1107 return Err(BoxError::ConfigError(
1108 "Host root cannot be used as a Sandbox rootfs".to_string(),
1109 ));
1110 }
1111 Ok(())
1112}
1113
1114fn validate_init_path(path: &str) -> Result<()> {
1115 if matches!(path, SBIN_INIT | USR_SBIN_INIT) {
1116 Ok(())
1117 } else {
1118 Err(BoxError::ConfigError(format!(
1119 "Sandbox init path must be {SBIN_INIT} or {USR_SBIN_INIT}: {path:?}"
1120 )))
1121 }
1122}
1123
1124fn validate_host_absolute_normalized(path: &Path, label: &str) -> Result<()> {
1125 if !path.is_absolute()
1126 || path
1127 .components()
1128 .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
1129 {
1130 return Err(BoxError::ConfigError(format!(
1131 "Sandbox {label} must be an absolute normalized path: {}",
1132 path.display()
1133 )));
1134 }
1135 Ok(())
1136}
1137
1138fn validate_linux_absolute_normalized(path: &Path, label: &str) -> Result<()> {
1139 let Some(path) = path.to_str() else {
1140 return Err(BoxError::ConfigError(format!(
1141 "Sandbox {label} must be a UTF-8 Linux path"
1142 )));
1143 };
1144 if !path.starts_with('/')
1145 || path.contains('\0')
1146 || path
1147 .split('/')
1148 .any(|component| matches!(component, "." | ".."))
1149 {
1150 return Err(BoxError::ConfigError(format!(
1151 "Sandbox {label} must be an absolute normalized Linux path: {path}"
1152 )));
1153 }
1154 Ok(())
1155}
1156
1157fn host_path_is_or_below(path: &Path, protected: &Path) -> bool {
1158 path == protected || (protected != Path::new("/") && path.starts_with(protected))
1159}
1160
1161fn linux_path_is_or_below(path: &Path, protected: &Path) -> bool {
1162 let Some(path) = path.to_str() else {
1163 return false;
1164 };
1165 let Some(protected) = protected.to_str() else {
1166 return false;
1167 };
1168 path == protected
1169 || (protected != "/"
1170 && path
1171 .strip_prefix(protected)
1172 .is_some_and(|suffix| suffix.starts_with('/')))
1173}
1174
1175fn validate_box_id(box_id: &str) -> Result<()> {
1176 if box_id.is_empty()
1177 || box_id.len() > 128
1178 || !box_id
1179 .bytes()
1180 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1181 {
1182 return Err(BoxError::ConfigError(format!(
1183 "Invalid Sandbox box ID {box_id:?}"
1184 )));
1185 }
1186 Ok(())
1187}
1188
1189fn validate_hostname(hostname: &str) -> Result<()> {
1190 if hostname.is_empty()
1191 || hostname.len() > 253
1192 || hostname.split('.').any(|label| {
1193 label.is_empty()
1194 || label.len() > 63
1195 || label.starts_with('-')
1196 || label.ends_with('-')
1197 || !label
1198 .bytes()
1199 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1200 })
1201 {
1202 return Err(BoxError::ConfigError(format!(
1203 "Invalid Sandbox hostname {hostname:?}"
1204 )));
1205 }
1206 Ok(())
1207}
1208
1209fn validate_digest(label: &str, digest: &str) -> Result<()> {
1210 let Some(hex) = digest.strip_prefix("sha256:") else {
1211 return Err(BoxError::ConfigError(format!(
1212 "Sandbox {label} digest must use sha256"
1213 )));
1214 };
1215 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1216 return Err(BoxError::ConfigError(format!(
1217 "Invalid Sandbox {label} digest"
1218 )));
1219 }
1220 Ok(())
1221}
1222
1223fn validate_cpuset(value: &str) -> Result<()> {
1224 let first = value.as_bytes().first().copied();
1225 let last = value.as_bytes().last().copied();
1226 if value.is_empty()
1227 || matches!(first, Some(b',' | b'-'))
1228 || matches!(last, Some(b',' | b'-'))
1229 || !value
1230 .bytes()
1231 .all(|byte| byte.is_ascii_digit() || matches!(byte, b',' | b'-'))
1232 {
1233 return Err(BoxError::ConfigError(format!(
1234 "Invalid Sandbox cpuset {value:?}"
1235 )));
1236 }
1237 Ok(())
1238}
1239
1240fn masked_paths() -> Vec<String> {
1241 [
1242 "/proc/acpi",
1243 "/proc/asound",
1244 "/proc/kcore",
1245 "/proc/keys",
1246 "/proc/latency_stats",
1247 "/proc/sched_debug",
1248 "/proc/scsi",
1249 "/proc/timer_list",
1250 "/proc/timer_stats",
1251 "/sys/devices/virtual/powercap",
1252 "/sys/firmware",
1253 ]
1254 .into_iter()
1255 .map(ToString::to_string)
1256 .collect()
1257}
1258
1259fn readonly_paths() -> Vec<String> {
1260 [
1261 "/proc/bus",
1262 "/proc/fs",
1263 "/proc/irq",
1264 "/proc/sys",
1265 "/proc/sysrq-trigger",
1266 ]
1267 .into_iter()
1268 .map(ToString::to_string)
1269 .collect()
1270}
1271
1272fn oci_error(error: impl std::fmt::Display) -> BoxError {
1273 BoxError::ConfigError(format!("Failed to compile Sandbox OCI spec: {error}"))
1274}
1275
1276const ALLOWED_SYSCALLS: &[&str] = &[
1280 "accept",
1281 "accept4",
1282 "access",
1283 "arch_prctl",
1284 "bind",
1285 "brk",
1286 "capget",
1287 "capset",
1288 "chdir",
1289 "chmod",
1290 "chown",
1291 "clock_getres",
1292 "clock_gettime",
1293 "clock_nanosleep",
1294 "close",
1295 "close_range",
1296 "connect",
1297 "copy_file_range",
1298 "creat",
1299 "dup",
1300 "dup2",
1301 "dup3",
1302 "epoll_create",
1303 "epoll_create1",
1304 "epoll_ctl",
1305 "epoll_pwait",
1306 "epoll_pwait2",
1307 "epoll_wait",
1308 "eventfd",
1309 "eventfd2",
1310 "execve",
1311 "execveat",
1312 "exit",
1313 "exit_group",
1314 "faccessat",
1315 "faccessat2",
1316 "fadvise64",
1317 "fallocate",
1318 "fchdir",
1319 "fchmod",
1320 "fchmodat",
1321 "fchown",
1322 "fchownat",
1323 "fcntl",
1324 "fdatasync",
1325 "fgetxattr",
1326 "flistxattr",
1327 "flock",
1328 "fork",
1329 "fremovexattr",
1330 "fsetxattr",
1331 "fstat",
1332 "fstatat",
1336 "fstatfs",
1337 "fsync",
1338 "ftruncate",
1339 "futex",
1340 "futex_waitv",
1341 "getcwd",
1342 "getdents",
1343 "getdents64",
1344 "getegid",
1345 "geteuid",
1346 "getgid",
1347 "getgroups",
1348 "getitimer",
1349 "getpeername",
1350 "getpgid",
1351 "getpgrp",
1352 "getpid",
1353 "getppid",
1354 "getpriority",
1355 "getrandom",
1356 "getresgid",
1357 "getresuid",
1358 "getrlimit",
1359 "get_robust_list",
1360 "getrusage",
1361 "getsid",
1362 "getsockname",
1363 "getsockopt",
1364 "gettid",
1365 "gettimeofday",
1366 "getuid",
1367 "getxattr",
1368 "inotify_add_watch",
1369 "inotify_init",
1370 "inotify_init1",
1371 "inotify_rm_watch",
1372 "ioctl",
1373 "ioprio_get",
1374 "ioprio_set",
1375 "kill",
1376 "lchown",
1377 "lgetxattr",
1378 "link",
1379 "linkat",
1380 "listen",
1381 "listxattr",
1382 "llistxattr",
1383 "lremovexattr",
1384 "lseek",
1385 "lsetxattr",
1386 "lstat",
1387 "madvise",
1388 "membarrier",
1389 "memfd_create",
1390 "mincore",
1391 "mkdir",
1392 "mkdirat",
1393 "mlock",
1394 "mlock2",
1395 "mlockall",
1396 "mmap",
1397 "mprotect",
1398 "mremap",
1399 "msync",
1400 "munlock",
1401 "munlockall",
1402 "munmap",
1403 "nanosleep",
1404 "newfstatat",
1405 "open",
1406 "openat",
1407 "openat2",
1408 "pause",
1409 "pidfd_open",
1410 "pidfd_send_signal",
1411 "pipe",
1412 "pipe2",
1413 "poll",
1414 "ppoll",
1415 "prctl",
1416 "pread64",
1417 "preadv",
1418 "preadv2",
1419 "prlimit64",
1420 "process_madvise",
1421 "process_vm_readv",
1422 "process_vm_writev",
1423 "pselect6",
1424 "pwrite64",
1425 "pwritev",
1426 "pwritev2",
1427 "read",
1428 "readahead",
1429 "readlink",
1430 "readlinkat",
1431 "readv",
1432 "recvfrom",
1433 "recvmmsg",
1434 "recvmsg",
1435 "rename",
1436 "renameat",
1437 "renameat2",
1438 "restart_syscall",
1439 "rseq",
1440 "rt_sigaction",
1441 "rt_sigpending",
1442 "rt_sigprocmask",
1443 "rt_sigqueueinfo",
1444 "rt_sigreturn",
1445 "rt_sigsuspend",
1446 "rt_sigtimedwait",
1447 "rt_tgsigqueueinfo",
1448 "sched_getaffinity",
1449 "sched_getattr",
1450 "sched_getparam",
1451 "sched_getscheduler",
1452 "sched_get_priority_max",
1453 "sched_get_priority_min",
1454 "sched_setaffinity",
1455 "sched_setattr",
1456 "sched_setparam",
1457 "sched_setscheduler",
1458 "sched_yield",
1459 "seccomp",
1460 "select",
1461 "semctl",
1462 "semget",
1463 "semop",
1464 "semtimedop",
1465 "sendfile",
1466 "sendmmsg",
1467 "sendmsg",
1468 "sendto",
1469 "set_robust_list",
1470 "set_tid_address",
1471 "setfsgid",
1472 "setfsuid",
1473 "setgid",
1474 "setgroups",
1475 "setitimer",
1476 "setpgid",
1477 "setpriority",
1478 "setregid",
1479 "setresgid",
1480 "setresuid",
1481 "setreuid",
1482 "setrlimit",
1483 "setsid",
1484 "setsockopt",
1485 "setuid",
1486 "shmat",
1487 "shmctl",
1488 "shmdt",
1489 "shmget",
1490 "shutdown",
1491 "sigaltstack",
1492 "signalfd",
1493 "signalfd4",
1494 "socket",
1495 "socketpair",
1496 "splice",
1497 "stat",
1498 "statfs",
1499 "statx",
1500 "symlink",
1501 "symlinkat",
1502 "sync",
1503 "sync_file_range",
1504 "syncfs",
1505 "sysinfo",
1506 "tee",
1507 "tgkill",
1508 "time",
1509 "timer_create",
1510 "timer_delete",
1511 "timer_getoverrun",
1512 "timer_gettime",
1513 "timer_settime",
1514 "timerfd_create",
1515 "timerfd_gettime",
1516 "timerfd_settime",
1517 "times",
1518 "tkill",
1519 "truncate",
1520 "umask",
1521 "uname",
1522 "unlink",
1523 "unlinkat",
1524 "utime",
1525 "utimensat",
1526 "utimes",
1527 "vfork",
1528 "vmsplice",
1529 "wait4",
1530 "waitid",
1531 "write",
1532 "writev",
1533];
1534
1535#[cfg(test)]
1536mod tests {
1537 use super::*;
1538 use serde_json::Value;
1539
1540 fn sample_input() -> SandboxBundleSpec {
1541 SandboxBundleSpec {
1542 box_id: "box-123".to_string(),
1543 rootfs_path: std::env::temp_dir().join("a3s/boxes/box-123/rootfs"),
1544 rootfs_read_only: false,
1545 hostname: "box-123".to_string(),
1546 init_path: "/sbin/init".to_string(),
1547 init_environment: vec![
1548 ("PATH".to_string(), "/bin".to_string()),
1549 (
1550 "A3S_BOOTSTRAP_MODE".to_string(),
1551 "attacker-value".to_string(),
1552 ),
1553 ],
1554 mounts: vec![SandboxMount {
1555 source: std::env::temp_dir().join("a3s/workspaces/box-123"),
1556 destination: PathBuf::from("/workspace"),
1557 read_only: false,
1558 }],
1559 tmpfs: Vec::new(),
1560 id_mappings: SandboxIdMappingPlan {
1561 uid_mappings: vec![IdMapping {
1562 container_id: 0,
1563 host_id: 100000,
1564 size: 65536,
1565 }],
1566 gid_mappings: vec![IdMapping {
1567 container_id: 0,
1568 host_id: 200000,
1569 size: 65536,
1570 }],
1571 maximum_container_uid: 65535,
1572 maximum_container_gid: 65535,
1573 },
1574 resources: SandboxResources {
1575 memory_limit: 512 * 1024 * 1024,
1576 memory_reservation: Some(256 * 1024 * 1024),
1577 memory_swap: Some(1024 * 1024 * 1024),
1578 cpu_shares: Some(1024),
1579 cpu_quota: 200000,
1580 cpu_period: 100000,
1581 cpuset_cpus: Some("0-1".to_string()),
1582 pids_limit: 512,
1583 },
1584 requested_capabilities: Vec::new(),
1585 execution_plan_digest: format!("sha256:{}", "a".repeat(64)),
1586 runtime_digest: format!("sha256:{}", "b".repeat(64)),
1587 }
1588 }
1589
1590 fn as_json(spec: &Spec) -> Value {
1591 serde_json::to_value(spec).unwrap()
1592 }
1593
1594 fn sample_runtime_process() -> SandboxRuntimeProcess {
1595 SandboxRuntimeProcess {
1596 args: vec!["/usr/bin/example".to_string(), "--serve".to_string()],
1597 environment: vec![
1598 ("APP_MODE".to_string(), "production".to_string()),
1599 ("A3S_EXEC_LISTENER_FD".to_string(), "99".to_string()),
1600 ("A3S_PTY_LISTENER_FD".to_string(), "98".to_string()),
1601 ("A3S_INIT_LOG_FD".to_string(), "97".to_string()),
1602 ("A3S_BOOTSTRAP_MODE".to_string(), "image-value".to_string()),
1603 ],
1604 cwd: PathBuf::from("/workspace"),
1605 uid: 123,
1606 gid: 456,
1607 additional_gids: vec![789],
1608 dropped_capabilities: vec!["SETUID".to_string()],
1609 }
1610 }
1611
1612 #[test]
1613 fn portable_microvm_compiler_uses_relative_root_and_no_user_namespace() {
1614 let value = as_json(
1615 &compile_portable_microvm_oci_spec("box-123", "box-123", &sample_runtime_process())
1616 .unwrap(),
1617 );
1618
1619 assert_eq!(value["ociVersion"], "1.3.0");
1620 assert_eq!(value["root"]["path"], "rootfs");
1621 assert_eq!(value["root"]["readonly"], false);
1622 assert_eq!(
1623 value["annotations"][PORTABLE_ROOTFS_METADATA_ANNOTATION],
1624 PORTABLE_ROOTFS_METADATA_SCHEMA_V1
1625 );
1626 assert_eq!(
1627 value["annotations"][RUNTIME_BUNDLE_HANDOFF_EXTENSION],
1628 RUNTIME_BUNDLE_HANDOFF_MOVE_V1
1629 );
1630 assert_eq!(
1631 value["annotations"]["com.a3s.box.isolation-class"],
1632 "hardware-vm"
1633 );
1634 let namespaces = value["linux"]["namespaces"].as_array().unwrap();
1635 assert!(namespaces
1636 .iter()
1637 .any(|namespace| namespace["type"] == "mount"));
1638 assert!(!namespaces
1639 .iter()
1640 .any(|namespace| namespace["type"] == "user"));
1641 assert_eq!(value["mounts"][0]["destination"], "/proc");
1642 }
1643
1644 #[test]
1645 fn runtime_owned_compiler_uses_direct_process_without_bootstrap_descriptors() {
1646 let value = as_json(
1647 &compile_runtime_owned_oci_spec(&sample_input(), &sample_runtime_process()).unwrap(),
1648 );
1649 assert_eq!(
1650 value["process"]["args"],
1651 serde_json::json!(["/usr/bin/example", "--serve"])
1652 );
1653 assert_eq!(value["process"]["cwd"], "/workspace");
1654 assert_eq!(value["process"]["user"]["uid"], 123);
1655 assert_eq!(value["process"]["user"]["gid"], 456);
1656 assert_eq!(
1657 value["process"]["user"]["additionalGids"],
1658 serde_json::json!([789])
1659 );
1660 assert_eq!(
1661 value["annotations"]["com.a3s.box.process-owner"],
1662 "a3s-oci-runtime"
1663 );
1664 let annotations = value["annotations"].as_object().unwrap();
1665 for annotation in [
1666 CONTROL_WORKLOAD_CGROUP_LAYOUT_ANNOTATION,
1667 CONTROL_CGROUP_MEMORY_HEADROOM_ANNOTATION,
1668 CONTROL_CGROUP_CPU_HEADROOM_ANNOTATION,
1669 CONTROL_CGROUP_PIDS_HEADROOM_ANNOTATION,
1670 ] {
1671 assert!(
1672 !annotations.contains_key(annotation),
1673 "runtime-owned process retained guest-init cgroup annotation {annotation}"
1674 );
1675 }
1676 assert_eq!(
1677 value["linux"]["resources"]["memory"]["limit"],
1678 512 * 1024 * 1024i64
1679 );
1680 let environment = value["process"]["env"].as_array().unwrap();
1681 assert!(environment
1682 .iter()
1683 .any(|value| value == "APP_MODE=production"));
1684 for reserved in RESERVED_BOOTSTRAP_ENVIRONMENT {
1685 assert!(
1686 !environment.iter().any(|value| value
1687 .as_str()
1688 .is_some_and(|value| value.starts_with(&format!("{reserved}=")))),
1689 "runtime-owned process retained {reserved}"
1690 );
1691 }
1692 let bounding = value["process"]["capabilities"]["bounding"]
1693 .as_array()
1694 .unwrap();
1695 assert!(!bounding.iter().any(|value| value == "CAP_SETUID"));
1696 assert!(bounding.iter().any(|value| value == "CAP_CHOWN"));
1697 }
1698
1699 #[test]
1700 fn runtime_owned_compiler_rejects_relative_executable_and_unmapped_user() {
1701 let input = sample_input();
1702 let mut process = sample_runtime_process();
1703 process.args[0] = "example".to_string();
1704 assert!(compile_runtime_owned_oci_spec(&input, &process).is_err());
1705
1706 process.args[0] = "/usr/bin/example".to_string();
1707 process.uid = input.id_mappings.maximum_container_uid + 1;
1708 assert!(compile_runtime_owned_oci_spec(&input, &process).is_err());
1709
1710 process.uid = 0;
1711 process.dropped_capabilities = vec!["NOT_A_CAPABILITY".to_string()];
1712 assert!(compile_runtime_owned_oci_spec(&input, &process).is_err());
1713 }
1714
1715 #[test]
1716 fn compiler_emits_every_mandatory_isolation_control() {
1717 let value = as_json(&compile_oci_spec(&sample_input()).unwrap());
1718 let namespaces: HashSet<_> = value["linux"]["namespaces"]
1719 .as_array()
1720 .unwrap()
1721 .iter()
1722 .map(|entry| entry["type"].as_str().unwrap())
1723 .collect();
1724 for required in ["user", "mount", "pid", "ipc", "uts", "network", "cgroup"] {
1725 assert!(namespaces.contains(required), "missing {required}");
1726 }
1727 assert_eq!(value["process"]["args"], serde_json::json!(["/sbin/init"]));
1728 assert_eq!(value["process"]["noNewPrivileges"], true);
1729 assert_eq!(value["linux"]["seccomp"]["defaultAction"], "SCMP_ACT_ERRNO");
1730 let expected_seccomp_architecture = match std::env::consts::ARCH {
1731 "x86_64" => "SCMP_ARCH_X86_64",
1732 "aarch64" => "SCMP_ARCH_AARCH64",
1733 architecture => panic!("unexpected test architecture {architecture}"),
1734 };
1735 assert_eq!(
1736 value["linux"]["seccomp"]["architectures"],
1737 serde_json::json!([expected_seccomp_architecture])
1738 );
1739 assert_eq!(
1740 value["linux"]["resources"]["memory"]["limit"],
1741 512 * 1024 * 1024i64
1742 );
1743 assert_eq!(
1744 value["linux"]["resources"]["memory"]["swap"],
1745 1024 * 1024 * 1024i64
1746 );
1747 assert_eq!(value["linux"]["resources"]["cpu"]["quota"], 200000);
1748 assert_eq!(value["linux"]["resources"]["pids"]["limit"], 512);
1749 assert_eq!(value["linux"]["resources"]["cpu"]["cpus"], "0-1");
1750 assert_eq!(
1751 value["annotations"][a3s_oci_sdk::CONTROL_WORKLOAD_CGROUP_LAYOUT_ANNOTATION],
1752 a3s_oci_sdk::CONTROL_WORKLOAD_CGROUP_LAYOUT_V1
1753 );
1754 assert_eq!(
1755 value["annotations"][a3s_oci_sdk::CONTROL_CGROUP_MEMORY_HEADROOM_ANNOTATION],
1756 SANDBOX_CONTROL_MEMORY_HEADROOM_BYTES.to_string()
1757 );
1758 assert_eq!(
1759 value["annotations"][a3s_oci_sdk::CONTROL_CGROUP_CPU_HEADROOM_ANNOTATION],
1760 DEFAULT_CPU_PERIOD_US.to_string()
1761 );
1762 assert_eq!(
1763 value["annotations"][a3s_oci_sdk::CONTROL_CGROUP_PIDS_HEADROOM_ANNOTATION],
1764 SANDBOX_CONTROL_PIDS_HEADROOM.to_string()
1765 );
1766 let cgroup_mount = value["mounts"]
1767 .as_array()
1768 .unwrap()
1769 .iter()
1770 .find(|mount| mount["destination"] == "/sys/fs/cgroup")
1771 .expect("cgroup mount");
1772 assert_eq!(cgroup_mount["type"], "cgroup");
1773 assert!(cgroup_mount["options"]
1774 .as_array()
1775 .unwrap()
1776 .iter()
1777 .any(|option| option == "ro"));
1778 assert!(!cgroup_mount["options"]
1779 .as_array()
1780 .unwrap()
1781 .iter()
1782 .any(|option| option == "rw"));
1783 }
1784
1785 #[test]
1786 fn compiler_uses_the_resolved_usr_sbin_init_path() {
1787 let mut input = sample_input();
1788 input.init_path = "/usr/sbin/init".to_string();
1789
1790 let value = as_json(&compile_oci_spec(&input).unwrap());
1791
1792 assert_eq!(
1793 value["process"]["args"],
1794 serde_json::json!(["/usr/sbin/init"])
1795 );
1796 }
1797
1798 #[test]
1799 fn compiler_rejects_an_unresolved_init_path() {
1800 let mut input = sample_input();
1801 input.init_path = "/bin/sh".to_string();
1802
1803 let error = compile_oci_spec(&input).unwrap_err().to_string();
1804
1805 assert!(error.contains("Sandbox init path"), "{error}");
1806 }
1807
1808 #[test]
1809 fn compiler_seals_bootstrap_environment_and_capabilities() {
1810 let value = as_json(&compile_oci_spec(&sample_input()).unwrap());
1811 let env = value["process"]["env"].as_array().unwrap();
1812 assert!(env
1813 .iter()
1814 .any(|value| value == "A3S_BOOTSTRAP_MODE=host-sandbox"));
1815 assert!(env.iter().any(|value| value == "A3S_EXEC_LISTENER_FD=3"));
1816 assert!(env.iter().any(|value| value == "A3S_PTY_LISTENER_FD=4"));
1817 assert!(env.iter().any(|value| value == "A3S_INIT_LOG_FD=5"));
1818 assert!(!env
1819 .iter()
1820 .any(|value| value == "A3S_BOOTSTRAP_MODE=attacker-value"));
1821
1822 let bounding = value["process"]["capabilities"]["bounding"]
1823 .as_array()
1824 .unwrap();
1825 assert!(!bounding.iter().any(|value| value == "CAP_SYS_ADMIN"));
1826 assert!(!bounding.iter().any(|value| value == "CAP_NET_RAW"));
1827 }
1828
1829 #[test]
1830 fn seccomp_masks_clone_namespace_flags_and_returns_enosys_for_clone3() {
1831 let value = as_json(&compile_oci_spec(&sample_input()).unwrap());
1832 let rules = value["linux"]["seccomp"]["syscalls"].as_array().unwrap();
1833 let clone = rules
1834 .iter()
1835 .find(|rule| {
1836 rule["names"]
1837 .as_array()
1838 .unwrap()
1839 .iter()
1840 .any(|name| name == "clone")
1841 })
1842 .unwrap();
1843 assert_eq!(clone["args"][0]["op"], "SCMP_CMP_MASKED_EQ");
1844 let clone3 = rules
1845 .iter()
1846 .find(|rule| {
1847 rule["names"]
1848 .as_array()
1849 .unwrap()
1850 .iter()
1851 .any(|name| name == "clone3")
1852 })
1853 .unwrap();
1854 assert_eq!(clone3["errnoRet"], LINUX_ENOSYS);
1855 let allowed_names = rules[0]["names"].as_array().unwrap();
1856 for forbidden in [
1857 "unshare",
1858 "setns",
1859 "mount",
1860 "pivot_root",
1861 "bpf",
1862 "keyctl",
1863 "perf_event_open",
1864 "io_uring_setup",
1865 "userfaultfd",
1866 "reboot",
1867 ] {
1868 assert!(!allowed_names.iter().any(|name| name == forbidden));
1869 }
1870 }
1871
1872 #[test]
1873 fn seccomp_allows_namespaced_sysv_shared_memory_for_postgresql() {
1874 let value = as_json(&compile_oci_spec(&sample_input()).unwrap());
1875 let allowed_names = value["linux"]["seccomp"]["syscalls"][0]["names"]
1876 .as_array()
1877 .unwrap();
1878
1879 for required in ["shmat", "shmctl", "shmdt", "shmget"] {
1880 assert!(
1881 allowed_names.iter().any(|name| name == required),
1882 "missing {required}"
1883 );
1884 }
1885 for forbidden in ["mount", "setns", "unshare", "bpf"] {
1886 assert!(!allowed_names.iter().any(|name| name == forbidden));
1887 }
1888 }
1889
1890 #[test]
1891 fn compiler_rejects_protected_or_duplicate_mounts() {
1892 let mut input = sample_input();
1893 input.mounts[0].source = PathBuf::from("/run/containerd/containerd.sock");
1894 assert!(compile_oci_spec(&input).is_err());
1895
1896 let mut input = sample_input();
1897 input.mounts.push(input.mounts[0].clone());
1898 assert!(compile_oci_spec(&input).is_err());
1899
1900 let mut input = sample_input();
1901 input.mounts[0].destination = PathBuf::from("/usr/sbin/init");
1902 assert!(compile_oci_spec(&input).is_err());
1903 }
1904
1905 #[test]
1906 fn compiler_rejects_mounts_at_or_below_metadata_temp_paths() {
1907 for destination in [
1908 "/.a3s-box-exec.json",
1909 "/.a3s-box-exec.json/child",
1910 "/.a3s_image_metadata_v1.json.tmp",
1911 "/.a3s_image_metadata_v1.json.tmp/child",
1912 "/.a3s_rootfs_metadata_v1.json.tmp",
1913 "/.a3s_rootfs_metadata_v1.json.tmp/child",
1914 ] {
1915 let mut input = sample_input();
1916 input.mounts[0].destination = PathBuf::from(destination);
1917 assert!(compile_oci_spec(&input).is_err(), "accepted {destination}");
1918 }
1919 }
1920
1921 #[test]
1922 fn compiler_allows_only_the_exact_shared_memory_tmpfs_override() {
1923 let mut input = sample_input();
1924 input.tmpfs.push(SandboxTmpfs {
1925 destination: PathBuf::from("/dev/shm"),
1926 size_bytes: 128 * 1024 * 1024,
1927 read_only: true,
1928 });
1929
1930 let value = as_json(&compile_oci_spec(&input).unwrap());
1931 let shared_memory = value["mounts"]
1932 .as_array()
1933 .unwrap()
1934 .iter()
1935 .filter(|mount| mount["destination"] == "/dev/shm")
1936 .collect::<Vec<_>>();
1937 assert_eq!(shared_memory.len(), 1);
1938 let options = shared_memory[0]["options"].as_array().unwrap();
1939 assert!(options.iter().any(|option| option == "size=134217728"));
1940 assert!(options.iter().any(|option| option == "noexec"));
1941 assert!(options.iter().any(|option| option == "ro"));
1942
1943 input.tmpfs[0].destination = PathBuf::from("/dev/shm/nested");
1944 assert!(compile_oci_spec(&input).is_err());
1945 }
1946
1947 #[test]
1948 fn resource_conversion_enforces_hard_limits_and_baseline_pids() {
1949 let config = BoxConfig::default();
1950 let resources = SandboxResources::from_box_config(&config).unwrap();
1951 assert_eq!(resources.memory_limit, 1024 * 1024 * 1024);
1952 assert_eq!(
1953 resources.cpu_quota,
1954 i64::from(a3s_box_core::config::DEFAULT_VCPUS) * 100000
1955 );
1956 assert_eq!(resources.cpu_period, 100000);
1957 assert_eq!(resources.pids_limit, DEFAULT_SANDBOX_PIDS_LIMIT);
1958 }
1959
1960 #[test]
1961 fn linux_guest_path_validation_is_host_independent() {
1962 validate_linux_absolute_normalized(Path::new("/workspace"), "test path").unwrap();
1963 assert!(validate_linux_absolute_normalized(Path::new("workspace"), "test path").is_err());
1964 assert!(
1965 validate_linux_absolute_normalized(Path::new("/work/../escape"), "test path").is_err()
1966 );
1967 }
1968}