Skip to main content

a3s_box_runtime/sandbox/
oci.rs

1//! Generated OCI specification for the certified Sandbox backend.
2
3use 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
28/// OCI annotation schema for generated A3S Sandbox bundles.
29pub const SANDBOX_BUNDLE_SCHEMA: &str = "a3s.box.sandbox-bundle.v1";
30/// Qualification schema for a portable one-VM-per-container bundle.
31pub const PORTABLE_MICROVM_BUNDLE_SCHEMA: &str = "a3s.box.portable-microvm-bundle.v1";
32/// Baseline process count enforced even when the caller omits `--pids-limit`.
33pub 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/// A host path deliberately exposed inside the Sandbox.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SandboxMount {
48    pub source: PathBuf,
49    pub destination: PathBuf,
50    pub read_only: bool,
51}
52
53/// A generated tmpfs mount with a bounded byte size.
54#[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/// Cgroup values compiled from `BoxConfig`.
62#[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    /// Convert public resource intent into strict OCI cgroup controls.
76    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/// Backend-neutral inputs already validated and resolved by the runtime.
188#[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    /// Guest-init location already resolved against the prepared rootfs.
195    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/// Container process compiled for the long-lived A3S OCI Runtime owner.
207///
208/// The executable, user and working directory are resolved against the
209/// prepared rootfs before this value reaches the compiler. Bootstrap listener
210/// descriptors deliberately have no representation here.
211#[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
241/// Compile a complete OCI config. Arbitrary caller-provided OCI JSON is never
242/// accepted by this backend.
243pub 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
267/// Compile a complete OCI config whose PID 1 and I/O are owned directly by
268/// A3S OCI Runtime. The legacy guest-init listener variables are removed even
269/// if an image attempts to provide them.
270pub 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
300/// Compile the bounded portable profile consumed by OCI Runtime's dedicated-VM driver.
301pub 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    // PID 1 needs these only for metadata replay, loopback setup, and dropping
552    // to the configured workload user. guest-init narrows the child process to
553    // the user-requested set before exec.
554    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    // `linux.resources` is the exact workload source of truth. The versioned
726    // A3S OCI layout derives its outer management envelope from the annotations
727    // emitted above and applies these values unchanged to `a3s-workload`.
728    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            // The runtime owns the complete topology and passes only two
863            // pre-opened membership descriptors to trusted guest-init. Paths
864            // remain read-only to the Sandbox user namespace.
865            &["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        // A user size for /tmp or /run replaces the generated default rather
942        // than creating two mounts at the same destination.
943        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    // clone is needed for threads/processes, but namespace creation stays
1017    // denied. clone3 deliberately returns ENOSYS so libc falls back to clone.
1018    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
1276// Default-deny profile for guest-init and general code execution. Namespace,
1277// mount, kernel-module, BPF, keyring, perf, io_uring, userfaultfd, reboot, and
1278// host-control syscalls are intentionally absent.
1279const 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    // Linux names syscall 79 `fstatat` on aarch64 and syscall 262
1333    // `newfstatat` on x86_64. Keep both portable aliases; the runtime drops
1334    // only the name absent from the selected architecture.
1335    "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}