Skip to main content

a3s_box_runtime/sandbox/
capability.rs

1//! Linux capability evidence for the shared-kernel Sandbox backend.
2
3use a3s_box_core::error::{BoxError, Result};
4use a3s_box_core::ExecutionBackend;
5use serde::{Deserialize, Serialize};
6#[cfg(target_os = "linux")]
7use sha2::{Digest, Sha256};
8#[cfg(target_os = "linux")]
9use std::fs::File;
10#[cfg(target_os = "linux")]
11use std::io::Read;
12#[cfg(target_os = "linux")]
13use std::path::Component;
14use std::path::{Path, PathBuf};
15
16/// Capability snapshot schema persisted with a resolved execution plan.
17pub const SANDBOX_CAPABILITY_SCHEMA: &str = "a3s.box.sandbox-capabilities.v1";
18
19#[cfg(target_os = "linux")]
20const REQUIRED_CGROUP_CONTROLLERS: &[&str] = &["cpu", "memory", "pids"];
21
22/// Verified A3S OCI Runtime artifacts used by the native Linux owner.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct CertifiedA3sOci {
25    pub runtime_path: PathBuf,
26    pub runtime_sha256: String,
27    pub agent_path: PathBuf,
28    pub agent_sha256: String,
29}
30
31/// One contiguous user-namespace ID mapping.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub struct IdMapping {
34    pub container_id: u32,
35    pub host_id: u32,
36    pub size: u32,
37}
38
39/// One subordinate ID range assigned to the service account.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub struct SubordinateIdRange {
42    pub start: u32,
43    pub size: u32,
44}
45
46/// Host identity and subordinate-ID evidence.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct UserNamespaceEvidence {
49    pub effective_uid: u32,
50    pub effective_gid: u32,
51    pub username: Option<String>,
52    pub max_user_namespaces: Option<u64>,
53    pub subordinate_uids: Vec<SubordinateIdRange>,
54    pub subordinate_gids: Vec<SubordinateIdRange>,
55}
56
57/// The exact mappings compiled into the OCI specification.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct SandboxIdMappingPlan {
60    pub uid_mappings: Vec<IdMapping>,
61    pub gid_mappings: Vec<IdMapping>,
62    pub maximum_container_uid: u32,
63    pub maximum_container_gid: u32,
64}
65
66/// Validate one exact Box-produced user-namespace mapping plan.
67///
68/// Persisted plans are recovery authorities: accepting a gap, overlap, or a
69/// host-root mapping could translate a stopped rootfs to the wrong container
70/// identities. Keep this validation shared with OCI bundle compilation so the
71/// live and recovery paths enforce the same contract.
72pub(crate) fn validate_id_mapping_plan(plan: &SandboxIdMappingPlan) -> Result<()> {
73    validate_mapping_set(&plan.uid_mappings, plan.maximum_container_uid, "UID")?;
74    validate_mapping_set(&plan.gid_mappings, plan.maximum_container_gid, "GID")?;
75    if plan
76        .uid_mappings
77        .iter()
78        .any(|mapping| mapping.container_id == 0 && mapping.host_id == 0)
79        || plan
80            .gid_mappings
81            .iter()
82            .any(|mapping| mapping.container_id == 0 && mapping.host_id == 0)
83    {
84        return Err(BoxError::ConfigError(
85            "Sandbox container root must not map to host root".to_string(),
86        ));
87    }
88    Ok(())
89}
90
91fn validate_mapping_set(mappings: &[IdMapping], maximum: u32, kind: &str) -> Result<()> {
92    if mappings.is_empty() || mappings[0].container_id != 0 {
93        return Err(BoxError::ConfigError(format!(
94            "Sandbox {kind} mappings must start at container ID 0"
95        )));
96    }
97    let mut next = 0u32;
98    let mut host_ranges = Vec::new();
99    for mapping in mappings {
100        if mapping.size == 0 || mapping.container_id != next {
101            return Err(BoxError::ConfigError(format!(
102                "Sandbox {kind} mappings must be contiguous and non-empty"
103            )));
104        }
105        next = next.checked_add(mapping.size).ok_or_else(|| {
106            BoxError::ConfigError(format!("Sandbox {kind} container mapping overflows"))
107        })?;
108        let host_end = mapping.host_id.checked_add(mapping.size).ok_or_else(|| {
109            BoxError::ConfigError(format!("Sandbox {kind} host mapping overflows"))
110        })?;
111        if host_ranges
112            .iter()
113            .any(|(start, end)| mapping.host_id < *end && *start < host_end)
114        {
115            return Err(BoxError::ConfigError(format!(
116                "Sandbox {kind} host mappings overlap"
117            )));
118        }
119        host_ranges.push((mapping.host_id, host_end));
120    }
121    if next <= maximum {
122        return Err(BoxError::ConfigError(format!(
123            "Sandbox {kind} mappings do not cover container ID {maximum}"
124        )));
125    }
126    Ok(())
127}
128
129/// cgroup v2 delegation evidence for the current service process.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct CgroupV2Evidence {
132    pub mountpoint: Option<PathBuf>,
133    pub current_path: Option<PathBuf>,
134    pub controllers: Vec<String>,
135    pub delegated: bool,
136}
137
138/// Serializable pre-launch evidence for every mandatory Sandbox control.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct SandboxCapabilitySnapshot {
141    pub schema: String,
142    pub platform: String,
143    pub architecture: String,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub a3s_oci: Option<CertifiedA3sOci>,
146    pub namespaces: Vec<String>,
147    pub user_namespace: Option<UserNamespaceEvidence>,
148    pub seccomp_actions: Vec<String>,
149    pub no_new_privileges_supported: bool,
150    pub capability_bounding_supported: bool,
151    pub cgroup_v2: CgroupV2Evidence,
152    pub failures: Vec<String>,
153}
154
155impl SandboxCapabilitySnapshot {
156    /// Whether all mandatory host controls were evidenced.
157    pub fn is_ready(&self) -> bool {
158        self.failures.is_empty()
159    }
160
161    /// Fail closed before rootfs preparation when a mandatory control is absent.
162    pub fn require_ready(&self) -> Result<()> {
163        if self.is_ready() {
164            return Ok(());
165        }
166
167        Err(BoxError::BoxBootError {
168            message: format!(
169                "Sandbox host capability check failed: {}",
170                self.failures.join("; ")
171            ),
172            hint: Some(
173                "Use an A3S Sandbox host with the selected runtime artifacts, user namespaces, and delegated cgroup v2"
174                    .to_string(),
175            ),
176        })
177    }
178}
179
180/// Probe the current host without changing namespaces, cgroups, or mounts.
181///
182/// An explicit runtime path is still subject to artifact validation. When
183/// omitted, only packaged A3S locations are searched; `PATH` is deliberately
184/// ignored.
185pub fn probe_sandbox_capabilities(runtime_path: Option<&Path>) -> SandboxCapabilitySnapshot {
186    probe_sandbox_capabilities_for(ExecutionBackend::A3sOci, runtime_path, None)
187}
188
189/// Probe host controls and the artifacts required by one Sandbox backend.
190pub fn probe_sandbox_capabilities_for(
191    backend: ExecutionBackend,
192    runtime_path: Option<&Path>,
193    agent_path: Option<&Path>,
194) -> SandboxCapabilitySnapshot {
195    let mut snapshot = SandboxCapabilitySnapshot {
196        schema: SANDBOX_CAPABILITY_SCHEMA.to_string(),
197        platform: std::env::consts::OS.to_string(),
198        architecture: std::env::consts::ARCH.to_string(),
199        a3s_oci: None,
200        namespaces: Vec::new(),
201        user_namespace: None,
202        seccomp_actions: Vec::new(),
203        no_new_privileges_supported: false,
204        capability_bounding_supported: false,
205        cgroup_v2: CgroupV2Evidence {
206            mountpoint: None,
207            current_path: None,
208            controllers: Vec::new(),
209            delegated: false,
210        },
211        failures: Vec::new(),
212    };
213
214    #[cfg(not(target_os = "linux"))]
215    {
216        let _ = (backend, runtime_path, agent_path);
217        snapshot
218            .failures
219            .push("Sandbox isolation is supported only on Linux".to_string());
220        snapshot
221    }
222
223    #[cfg(target_os = "linux")]
224    {
225        match backend {
226            ExecutionBackend::A3sOci => match resolve_a3s_oci_artifacts(runtime_path, agent_path) {
227                Ok(runtime) => snapshot.a3s_oci = Some(runtime),
228                Err(error) => snapshot.failures.push(error.to_string()),
229            },
230            ExecutionBackend::Krun => snapshot
231                .failures
232                .push("MicroVM execution is not a Sandbox backend".to_string()),
233        }
234
235        probe_namespaces(&mut snapshot);
236        probe_seccomp_and_privileges(&mut snapshot);
237        snapshot.cgroup_v2 = probe_cgroup_v2();
238        if !snapshot.cgroup_v2.delegated {
239            snapshot.failures.push(format!(
240                "cgroup v2 delegation is unavailable or lacks controllers: {}",
241                REQUIRED_CGROUP_CONTROLLERS.join(", ")
242            ));
243        }
244
245        snapshot
246    }
247}
248
249#[cfg(target_os = "linux")]
250fn resolve_a3s_oci_artifacts(
251    runtime_path: Option<&Path>,
252    agent_path: Option<&Path>,
253) -> Result<CertifiedA3sOci> {
254    let runtime_path = resolve_packaged_artifact(
255        runtime_path,
256        "A3S_BOX_OCI_RUNTIME_PATH",
257        "a3s-oci",
258        "A3S OCI Runtime",
259    )?;
260    let agent_path = resolve_packaged_artifact(
261        agent_path,
262        "A3S_BOX_OCI_AGENT_PATH",
263        "a3s-oci-agent",
264        "A3S OCI agent",
265    )?;
266    Ok(CertifiedA3sOci {
267        runtime_sha256: sha256_file(&runtime_path)?,
268        agent_sha256: sha256_file(&agent_path)?,
269        runtime_path,
270        agent_path,
271    })
272}
273
274#[cfg(target_os = "linux")]
275fn resolve_packaged_artifact(
276    explicit: Option<&Path>,
277    environment: &str,
278    filename: &str,
279    label: &str,
280) -> Result<PathBuf> {
281    let selected = if let Some(path) = explicit {
282        path.to_path_buf()
283    } else if let Some(path) = std::env::var_os(environment).filter(|path| !path.is_empty()) {
284        PathBuf::from(path)
285    } else {
286        let mut candidates = Vec::new();
287        if let Ok(executable) = std::env::current_exe() {
288            if let Some(directory) = executable.parent() {
289                candidates.push(directory.join(filename));
290                if directory.file_name().is_some_and(|name| name == "deps") {
291                    if let Some(target_directory) = directory.parent() {
292                        candidates.push(target_directory.join(filename));
293                    }
294                }
295            }
296        }
297        candidates.push(a3s_box_core::dirs_home().join("bin").join(filename));
298        candidates
299            .into_iter()
300            .find(|candidate| candidate.is_file())
301            .ok_or_else(|| BoxError::BoxBootError {
302                message: format!("{label} artifact was not found in packaged A3S locations"),
303                hint: Some(format!(
304                    "Install the A3S Box Sandbox runtime package or set {environment} to its packaged artifact"
305                )),
306            })?
307    };
308
309    let canonical = selected
310        .canonicalize()
311        .map_err(|error| BoxError::BoxBootError {
312            message: format!("Failed to resolve {label} {}: {error}", selected.display()),
313            hint: None,
314        })?;
315    let metadata = canonical
316        .metadata()
317        .map_err(|error| BoxError::BoxBootError {
318            message: format!("Failed to inspect {label} {}: {error}", canonical.display()),
319            hint: None,
320        })?;
321    if !metadata.is_file() {
322        return Err(BoxError::BoxBootError {
323            message: format!("{label} is not a regular file: {}", canonical.display()),
324            hint: None,
325        });
326    }
327    use std::os::unix::fs::PermissionsExt;
328    if metadata.permissions().mode() & 0o111 == 0 {
329        return Err(BoxError::BoxBootError {
330            message: format!("{label} is not executable: {}", canonical.display()),
331            hint: None,
332        });
333    }
334    Ok(canonical)
335}
336
337/// Produce complete UID and GID mappings for the IDs present in one rootfs.
338///
339/// A non-root service account maps container root to itself and consumes
340/// subordinate IDs from container ID 1 onward. A root service account must use
341/// subordinate IDs even for container root, so host UID/GID 0 are never mapped.
342pub fn plan_id_mappings(
343    evidence: &UserNamespaceEvidence,
344    maximum_container_uid: u32,
345    maximum_container_gid: u32,
346) -> Result<SandboxIdMappingPlan> {
347    let uid_mappings = allocate_id_mappings(
348        evidence.effective_uid,
349        &evidence.subordinate_uids,
350        maximum_container_uid,
351        "UID",
352    )?;
353    let gid_mappings = allocate_id_mappings(
354        evidence.effective_gid,
355        &evidence.subordinate_gids,
356        maximum_container_gid,
357        "GID",
358    )?;
359
360    if uid_mappings
361        .iter()
362        .any(|mapping| mapping.container_id == 0 && mapping.host_id == 0)
363        || gid_mappings
364            .iter()
365            .any(|mapping| mapping.container_id == 0 && mapping.host_id == 0)
366    {
367        return Err(BoxError::ConfigError(
368            "Sandbox container root must not map to host root".to_string(),
369        ));
370    }
371
372    Ok(SandboxIdMappingPlan {
373        uid_mappings,
374        gid_mappings,
375        maximum_container_uid,
376        maximum_container_gid,
377    })
378}
379
380/// Translate one container UID through the same mapping allocation used by
381/// Sandbox OCI specifications.
382pub fn map_container_uid(evidence: &UserNamespaceEvidence, uid: u32) -> Result<u32> {
383    map_container_identity(
384        evidence.effective_uid,
385        &evidence.subordinate_uids,
386        uid,
387        "UID",
388    )
389}
390
391/// Translate one container GID through the same mapping allocation used by
392/// Sandbox OCI specifications.
393pub fn map_container_gid(evidence: &UserNamespaceEvidence, gid: u32) -> Result<u32> {
394    map_container_identity(
395        evidence.effective_gid,
396        &evidence.subordinate_gids,
397        gid,
398        "GID",
399    )
400}
401
402/// Recover the container UID represented by a mapped host UID.
403pub fn unmap_host_uid(evidence: &UserNamespaceEvidence, uid: u32) -> Result<u32> {
404    unmap_host_identity(
405        evidence.effective_uid,
406        &evidence.subordinate_uids,
407        uid,
408        "UID",
409    )
410}
411
412/// Recover the container GID represented by a mapped host GID.
413pub fn unmap_host_gid(evidence: &UserNamespaceEvidence, gid: u32) -> Result<u32> {
414    unmap_host_identity(
415        evidence.effective_gid,
416        &evidence.subordinate_gids,
417        gid,
418        "GID",
419    )
420}
421
422fn map_container_identity(
423    effective_id: u32,
424    subordinate_ranges: &[SubordinateIdRange],
425    container_id: u32,
426    kind: &str,
427) -> Result<u32> {
428    let mappings = allocate_id_mappings(effective_id, subordinate_ranges, container_id, kind)?;
429    translate_container_id(&mappings, container_id, kind)
430}
431
432fn unmap_host_identity(
433    effective_id: u32,
434    subordinate_ranges: &[SubordinateIdRange],
435    host_id: u32,
436    kind: &str,
437) -> Result<u32> {
438    if effective_id != 0 && host_id == effective_id {
439        return Ok(0);
440    }
441
442    let mut next_container_id = u32::from(effective_id != 0);
443    for range in subordinate_ranges {
444        if range.size == 0 || range.start == 0 {
445            continue;
446        }
447        let Some(host_end) = range.start.checked_add(range.size) else {
448            continue;
449        };
450        if effective_id != 0 && range.start <= effective_id && effective_id < host_end {
451            continue;
452        }
453        if range.start <= host_id && host_id < host_end {
454            return next_container_id
455                .checked_add(host_id - range.start)
456                .ok_or_else(|| {
457                    BoxError::ConfigError(format!("Sandbox {kind} reverse mapping overflows u32"))
458                });
459        }
460        next_container_id = next_container_id.checked_add(range.size).ok_or_else(|| {
461            BoxError::ConfigError(format!("Sandbox {kind} mapping range overflows u32"))
462        })?;
463    }
464
465    Err(BoxError::ConfigError(format!(
466        "Sandbox host {kind} {host_id} is outside the configured mappings"
467    )))
468}
469
470fn translate_container_id(mappings: &[IdMapping], container_id: u32, kind: &str) -> Result<u32> {
471    for mapping in mappings {
472        let Some(end) = mapping.container_id.checked_add(mapping.size) else {
473            continue;
474        };
475        if mapping.container_id <= container_id && container_id < end {
476            return mapping
477                .host_id
478                .checked_add(container_id - mapping.container_id)
479                .ok_or_else(|| {
480                    BoxError::ConfigError(format!("Sandbox {kind} mapping overflows u32"))
481                });
482        }
483    }
484    Err(BoxError::ConfigError(format!(
485        "Sandbox mappings do not cover container {kind} {container_id}"
486    )))
487}
488
489fn allocate_id_mappings(
490    effective_id: u32,
491    subordinate_ranges: &[SubordinateIdRange],
492    maximum_container_id: u32,
493    kind: &str,
494) -> Result<Vec<IdMapping>> {
495    let mut mappings = Vec::new();
496    let mut next_container_id = 0u32;
497
498    if effective_id != 0 {
499        mappings.push(IdMapping {
500            container_id: 0,
501            host_id: effective_id,
502            size: 1,
503        });
504        next_container_id = 1;
505    }
506
507    let required_end = maximum_container_id
508        .checked_add(1)
509        .ok_or_else(|| BoxError::ConfigError(format!("Sandbox {kind} range overflows u32")))?;
510
511    for range in subordinate_ranges {
512        if next_container_id >= required_end {
513            break;
514        }
515        if range.size == 0 || range.start == 0 {
516            continue;
517        }
518        let host_end = match range.start.checked_add(range.size) {
519            Some(end) => end,
520            None => continue,
521        };
522        if effective_id != 0 && range.start <= effective_id && effective_id < host_end {
523            continue;
524        }
525
526        let remaining = required_end - next_container_id;
527        let size = remaining.min(range.size);
528        mappings.push(IdMapping {
529            container_id: next_container_id,
530            host_id: range.start,
531            size,
532        });
533        next_container_id += size;
534    }
535
536    if next_container_id < required_end {
537        return Err(BoxError::ConfigError(format!(
538            "Sandbox needs mappings through container {kind} {maximum_container_id}, but the service account has only {} mapped IDs",
539            next_container_id
540        )));
541    }
542
543    Ok(mappings)
544}
545
546#[cfg(target_os = "linux")]
547fn sha256_file(path: &Path) -> Result<String> {
548    let mut file = File::open(path)?;
549    let mut hasher = Sha256::new();
550    let mut buffer = [0u8; 64 * 1024];
551    loop {
552        let read = file.read(&mut buffer)?;
553        if read == 0 {
554            break;
555        }
556        hasher.update(&buffer[..read]);
557    }
558    Ok(hex::encode(hasher.finalize()))
559}
560
561#[cfg(target_os = "linux")]
562fn probe_namespaces(snapshot: &mut SandboxCapabilitySnapshot) {
563    const REQUIRED: &[(&str, &str)] = &[
564        ("user", "user namespace"),
565        ("mnt", "mount namespace"),
566        ("pid", "PID namespace"),
567        ("ipc", "IPC namespace"),
568        ("uts", "UTS namespace"),
569        ("net", "network namespace"),
570        ("cgroup", "cgroup namespace"),
571    ];
572
573    for (name, label) in REQUIRED {
574        if Path::new("/proc/self/ns").join(name).exists() {
575            snapshot.namespaces.push((*name).to_string());
576        } else {
577            snapshot
578                .failures
579                .push(format!("Kernel does not expose the required {label}"));
580        }
581    }
582
583    let effective_uid = unsafe { libc::geteuid() };
584    let effective_gid = unsafe { libc::getegid() };
585    let username = username_for_uid(effective_uid);
586    let max_user_namespaces = read_trimmed("/proc/sys/user/max_user_namespaces")
587        .and_then(|value| value.parse::<u64>().ok());
588    if max_user_namespaces == Some(0) || max_user_namespaces.is_none() {
589        snapshot
590            .failures
591            .push("User namespaces are disabled by the host".to_string());
592    }
593
594    let subordinate_uids =
595        read_subordinate_ranges("/etc/subuid", effective_uid, username.as_deref());
596    let subordinate_gids =
597        read_subordinate_ranges("/etc/subgid", effective_uid, username.as_deref());
598    if effective_uid == 0 && subordinate_uids.is_empty() {
599        snapshot.failures.push(
600            "A root-run Sandbox service requires a non-root subordinate UID range".to_string(),
601        );
602    }
603    if effective_gid == 0 && subordinate_gids.is_empty() {
604        snapshot.failures.push(
605            "A root-run Sandbox service requires a non-root subordinate GID range".to_string(),
606        );
607    }
608
609    snapshot.user_namespace = Some(UserNamespaceEvidence {
610        effective_uid,
611        effective_gid,
612        username,
613        max_user_namespaces,
614        subordinate_uids,
615        subordinate_gids,
616    });
617}
618
619#[cfg(target_os = "linux")]
620fn probe_seccomp_and_privileges(snapshot: &mut SandboxCapabilitySnapshot) {
621    snapshot.seccomp_actions = read_trimmed("/proc/sys/kernel/seccomp/actions_avail")
622        .map(|line| line.split_whitespace().map(ToString::to_string).collect())
623        .unwrap_or_default();
624    if !snapshot
625        .seccomp_actions
626        .iter()
627        .any(|action| action == "allow")
628        || !snapshot
629            .seccomp_actions
630            .iter()
631            .any(|action| action == "errno")
632    {
633        snapshot
634            .failures
635            .push("Kernel seccomp ERRNO/ALLOW actions are unavailable".to_string());
636    }
637
638    let status = read_trimmed("/proc/self/status").unwrap_or_default();
639    snapshot.no_new_privileges_supported =
640        status.lines().any(|line| line.starts_with("NoNewPrivs:"));
641    snapshot.capability_bounding_supported = status.lines().any(|line| line.starts_with("CapBnd:"));
642    if !snapshot.no_new_privileges_supported {
643        snapshot
644            .failures
645            .push("Kernel does not expose no_new_privs state".to_string());
646    }
647    if !snapshot.capability_bounding_supported {
648        snapshot
649            .failures
650            .push("Kernel does not expose a capability bounding set".to_string());
651    }
652}
653
654#[cfg(target_os = "linux")]
655fn probe_cgroup_v2() -> CgroupV2Evidence {
656    let mountinfo = read_trimmed("/proc/self/mountinfo");
657    let mountpoint = mountinfo.as_deref().and_then(parse_cgroup2_mountpoint);
658    let current_path = process_cgroup_v2_path(std::process::id());
659    let controllers: Vec<String> = current_path
660        .as_ref()
661        .and_then(|path| read_trimmed(path.join("cgroup.controllers")))
662        .map(|line| line.split_whitespace().map(ToString::to_string).collect())
663        .unwrap_or_default();
664    let has_controllers = REQUIRED_CGROUP_CONTROLLERS
665        .iter()
666        .all(|required| controllers.iter().any(|value| value == required));
667    let delegated = current_path.as_ref().is_some_and(|path| {
668        has_controllers
669            && path.join("cgroup.procs").exists()
670            && path.join("cgroup.subtree_control").exists()
671            && path_is_writable(path)
672            && path_is_writable(&path.join("cgroup.procs"))
673            && path_is_writable(&path.join("cgroup.subtree_control"))
674    });
675
676    CgroupV2Evidence {
677        mountpoint,
678        current_path,
679        controllers,
680        delegated,
681    }
682}
683
684/// Resolve the host cgroup v2 directory containing `pid`.
685///
686/// OCI runtimes may place a workload below a private manager cgroup, so callers
687/// must derive this path from the process rather than assume a fixed hierarchy.
688#[cfg(target_os = "linux")]
689pub(crate) fn process_cgroup_v2_path(pid: u32) -> Option<PathBuf> {
690    let mountinfo = read_trimmed("/proc/self/mountinfo")?;
691    let cgroup = read_trimmed(PathBuf::from("/proc").join(pid.to_string()).join("cgroup"))?;
692    parse_cgroup_v2_path(&mountinfo, &cgroup)
693}
694
695#[cfg(target_os = "linux")]
696fn parse_cgroup_v2_path(mountinfo: &str, cgroup: &str) -> Option<PathBuf> {
697    let mountpoint = parse_cgroup2_mountpoint(mountinfo)?;
698    let relative = parse_current_cgroup_path(cgroup)?;
699    safe_join_cgroup(&mountpoint, relative)
700}
701
702#[cfg(target_os = "linux")]
703fn parse_cgroup2_mountpoint(mountinfo: &str) -> Option<PathBuf> {
704    mountinfo.lines().find_map(|line| {
705        let (left, right) = line.split_once(" - ")?;
706        if right.split_whitespace().next()? != "cgroup2" {
707            return None;
708        }
709        let mountpoint = left.split_whitespace().nth(4)?;
710        Some(PathBuf::from(unescape_mountinfo(mountpoint)))
711    })
712}
713
714#[cfg(target_os = "linux")]
715fn parse_current_cgroup_path(contents: &str) -> Option<&str> {
716    contents.lines().find_map(|line| {
717        let mut fields = line.splitn(3, ':');
718        let hierarchy = fields.next()?;
719        let controllers = fields.next()?;
720        let path = fields.next()?;
721        (hierarchy == "0" && controllers.is_empty()).then_some(path)
722    })
723}
724
725#[cfg(target_os = "linux")]
726fn safe_join_cgroup(mountpoint: &Path, relative: &str) -> Option<PathBuf> {
727    let relative = Path::new(relative.trim_start_matches('/'));
728    if relative.components().any(|component| {
729        matches!(
730            component,
731            Component::ParentDir | Component::RootDir | Component::Prefix(_)
732        )
733    }) {
734        return None;
735    }
736    Some(mountpoint.join(relative))
737}
738
739#[cfg(target_os = "linux")]
740fn unescape_mountinfo(value: &str) -> String {
741    value
742        .replace("\\040", " ")
743        .replace("\\011", "\t")
744        .replace("\\012", "\n")
745        .replace("\\134", "\\")
746}
747
748#[cfg(target_os = "linux")]
749fn path_is_writable(path: &Path) -> bool {
750    use std::os::unix::ffi::OsStrExt;
751
752    let Ok(path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
753        return false;
754    };
755    unsafe { libc::access(path.as_ptr(), libc::W_OK) == 0 }
756}
757
758#[cfg(target_os = "linux")]
759fn read_trimmed(path: impl AsRef<Path>) -> Option<String> {
760    std::fs::read_to_string(path)
761        .ok()
762        .map(|value| value.trim().to_string())
763}
764
765#[cfg(target_os = "linux")]
766fn username_for_uid(uid: u32) -> Option<String> {
767    let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
768    passwd.lines().find_map(|line| {
769        if line.trim_start().starts_with('#') {
770            return None;
771        }
772        let mut fields = line.split(':');
773        let name = fields.next()?;
774        let _password = fields.next()?;
775        let entry_uid = fields.next()?.parse::<u32>().ok()?;
776        (entry_uid == uid).then(|| name.to_string())
777    })
778}
779
780#[cfg(target_os = "linux")]
781fn read_subordinate_ranges(
782    path: &str,
783    uid: u32,
784    username: Option<&str>,
785) -> Vec<SubordinateIdRange> {
786    let Some(contents) = read_trimmed(path) else {
787        return Vec::new();
788    };
789    parse_subordinate_ranges(&contents, uid, username)
790}
791
792#[cfg(any(target_os = "linux", test))]
793fn parse_subordinate_ranges(
794    contents: &str,
795    uid: u32,
796    username: Option<&str>,
797) -> Vec<SubordinateIdRange> {
798    let uid = uid.to_string();
799    let mut ranges: Vec<_> = contents
800        .lines()
801        .filter_map(|line| {
802            let line = line.split('#').next()?.trim();
803            let mut fields = line.split(':');
804            let owner = fields.next()?;
805            if owner != uid && username != Some(owner) {
806                return None;
807            }
808            let start = fields.next()?.parse::<u32>().ok()?;
809            let size = fields.next()?.parse::<u32>().ok()?;
810            if fields.next().is_some() || start == 0 || size == 0 {
811                return None;
812            }
813            start.checked_add(size)?;
814            Some(SubordinateIdRange { start, size })
815        })
816        .collect();
817    ranges.sort_by_key(|range| range.start);
818    ranges
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    #[cfg(target_os = "linux")]
826    #[test]
827    fn resolves_and_digests_explicit_a3s_oci_artifacts() {
828        use std::os::unix::fs::PermissionsExt;
829
830        let temporary = tempfile::tempdir().unwrap();
831        let runtime = temporary.path().join("a3s-oci");
832        let agent = temporary.path().join("a3s-oci-agent");
833        std::fs::write(&runtime, b"runtime-artifact").unwrap();
834        std::fs::write(&agent, b"agent-artifact").unwrap();
835        std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o700)).unwrap();
836        std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o700)).unwrap();
837
838        let artifacts = resolve_a3s_oci_artifacts(Some(&runtime), Some(&agent)).unwrap();
839
840        assert_eq!(artifacts.runtime_path, runtime.canonicalize().unwrap());
841        assert_eq!(artifacts.agent_path, agent.canonicalize().unwrap());
842        assert_eq!(artifacts.runtime_sha256.len(), 64);
843        assert_eq!(artifacts.agent_sha256.len(), 64);
844        assert_ne!(artifacts.runtime_sha256, artifacts.agent_sha256);
845    }
846
847    #[cfg(target_os = "linux")]
848    #[test]
849    fn rejects_a_non_executable_a3s_oci_artifact() {
850        use std::os::unix::fs::PermissionsExt;
851
852        let temporary = tempfile::tempdir().unwrap();
853        let runtime = temporary.path().join("a3s-oci");
854        let agent = temporary.path().join("a3s-oci-agent");
855        std::fs::write(&runtime, b"runtime-artifact").unwrap();
856        std::fs::write(&agent, b"agent-artifact").unwrap();
857        std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o600)).unwrap();
858        std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o700)).unwrap();
859
860        let error = resolve_a3s_oci_artifacts(Some(&runtime), Some(&agent)).unwrap_err();
861
862        assert!(error.to_string().contains("not executable"));
863    }
864
865    #[test]
866    fn subordinate_ranges_match_name_or_numeric_uid() {
867        let contents = "alice:100000:65536\n1001:200000:42\nbob:300000:5\n";
868        assert_eq!(
869            parse_subordinate_ranges(contents, 1001, Some("alice")),
870            vec![
871                SubordinateIdRange {
872                    start: 100000,
873                    size: 65536,
874                },
875                SubordinateIdRange {
876                    start: 200000,
877                    size: 42,
878                },
879            ]
880        );
881    }
882
883    #[test]
884    fn non_root_mapping_uses_effective_id_for_container_root() {
885        let evidence = UserNamespaceEvidence {
886            effective_uid: 1000,
887            effective_gid: 1000,
888            username: Some("box".to_string()),
889            max_user_namespaces: Some(1024),
890            subordinate_uids: vec![SubordinateIdRange {
891                start: 100000,
892                size: 65536,
893            }],
894            subordinate_gids: vec![SubordinateIdRange {
895                start: 200000,
896                size: 65536,
897            }],
898        };
899        let plan = plan_id_mappings(&evidence, 65535, 65535).unwrap();
900        assert_eq!(
901            plan.uid_mappings,
902            vec![
903                IdMapping {
904                    container_id: 0,
905                    host_id: 1000,
906                    size: 1,
907                },
908                IdMapping {
909                    container_id: 1,
910                    host_id: 100000,
911                    size: 65535,
912                },
913            ]
914        );
915        assert_eq!(plan.gid_mappings[0].host_id, 1000);
916        assert_eq!(plan.gid_mappings[1].host_id, 200000);
917    }
918
919    #[test]
920    fn root_service_maps_container_root_to_subordinate_id() {
921        let evidence = UserNamespaceEvidence {
922            effective_uid: 0,
923            effective_gid: 0,
924            username: Some("root".to_string()),
925            max_user_namespaces: Some(1024),
926            subordinate_uids: vec![SubordinateIdRange {
927                start: 100000,
928                size: 65536,
929            }],
930            subordinate_gids: vec![SubordinateIdRange {
931                start: 200000,
932                size: 65536,
933            }],
934        };
935        let plan = plan_id_mappings(&evidence, 65535, 65535).unwrap();
936        assert_eq!(plan.uid_mappings[0].container_id, 0);
937        assert_eq!(plan.uid_mappings[0].host_id, 100000);
938        assert_eq!(plan.gid_mappings[0].host_id, 200000);
939        assert_eq!(map_container_uid(&evidence, 0).unwrap(), 100000);
940        assert_eq!(map_container_uid(&evidence, 1000).unwrap(), 101000);
941        assert_eq!(unmap_host_uid(&evidence, 100000).unwrap(), 0);
942        assert_eq!(unmap_host_uid(&evidence, 101000).unwrap(), 1000);
943    }
944
945    #[test]
946    fn identity_translation_matches_multi_range_allocation() {
947        let evidence = UserNamespaceEvidence {
948            effective_uid: 1000,
949            effective_gid: 2000,
950            username: None,
951            max_user_namespaces: Some(1024),
952            subordinate_uids: vec![
953                SubordinateIdRange {
954                    start: 100000,
955                    size: 2,
956                },
957                SubordinateIdRange {
958                    start: 200000,
959                    size: 3,
960                },
961            ],
962            subordinate_gids: vec![SubordinateIdRange {
963                start: 300000,
964                size: 8,
965            }],
966        };
967
968        assert_eq!(map_container_uid(&evidence, 0).unwrap(), 1000);
969        assert_eq!(map_container_uid(&evidence, 1).unwrap(), 100000);
970        assert_eq!(map_container_uid(&evidence, 2).unwrap(), 100001);
971        assert_eq!(map_container_uid(&evidence, 3).unwrap(), 200000);
972        assert_eq!(unmap_host_uid(&evidence, 1000).unwrap(), 0);
973        assert_eq!(unmap_host_uid(&evidence, 200002).unwrap(), 5);
974        assert!(map_container_uid(&evidence, 6).is_err());
975        assert!(unmap_host_uid(&evidence, 42).is_err());
976        assert_eq!(map_container_gid(&evidence, 1).unwrap(), 300000);
977        assert_eq!(unmap_host_gid(&evidence, 300000).unwrap(), 1);
978    }
979
980    #[test]
981    fn one_id_rootless_mapping_is_allowed_only_when_sufficient() {
982        let evidence = UserNamespaceEvidence {
983            effective_uid: 1000,
984            effective_gid: 1000,
985            username: None,
986            max_user_namespaces: Some(1024),
987            subordinate_uids: Vec::new(),
988            subordinate_gids: Vec::new(),
989        };
990        assert!(plan_id_mappings(&evidence, 0, 0).is_ok());
991        assert!(plan_id_mappings(&evidence, 1, 0).is_err());
992    }
993
994    #[cfg(target_os = "linux")]
995    #[test]
996    fn parses_cgroup_v2_paths_without_traversal() {
997        let mountinfo =
998            "29 23 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw\n";
999        assert_eq!(
1000            parse_cgroup2_mountpoint(mountinfo).as_deref(),
1001            Some(Path::new("/sys/fs/cgroup"))
1002        );
1003        assert_eq!(
1004            parse_current_cgroup_path("0::/user.slice/a3s.service\n"),
1005            Some("/user.slice/a3s.service")
1006        );
1007        assert_eq!(
1008            parse_cgroup_v2_path(mountinfo, "0::/a3s-oci-42/a3s-box/unit-1\n").as_deref(),
1009            Some(Path::new("/sys/fs/cgroup/a3s-oci-42/a3s-box/unit-1"))
1010        );
1011        assert!(safe_join_cgroup(Path::new("/sys/fs/cgroup"), "/../../etc").is_none());
1012        assert!(parse_cgroup_v2_path(mountinfo, "0::/../../etc\n").is_none());
1013    }
1014}