1use 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
16pub 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub struct SubordinateIdRange {
42 pub start: u32,
43 pub size: u32,
44}
45
46#[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#[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
66pub(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#[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#[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 pub fn is_ready(&self) -> bool {
158 self.failures.is_empty()
159 }
160
161 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
180pub fn probe_sandbox_capabilities(runtime_path: Option<&Path>) -> SandboxCapabilitySnapshot {
186 probe_sandbox_capabilities_for(ExecutionBackend::A3sOci, runtime_path, None)
187}
188
189pub 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
337pub 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
380pub 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
391pub 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
402pub 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
412pub 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#[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}