1#[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
20pub const SANDBOX_CAPABILITY_SCHEMA: &str = "a3s.box.sandbox-capabilities.v1";
22
23pub 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52pub struct SubordinateIdRange {
53 pub start: u32,
54 pub size: u32,
55}
56
57#[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#[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#[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#[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 pub fn is_ready(&self) -> bool {
105 self.failures.is_empty()
106 }
107
108 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
127pub 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
186pub 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
229pub 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
240pub 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
251pub 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
261pub 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}