Skip to main content

a3s_box_runtime/sandbox/
capability.rs

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