From 1c8338ea666c09dbfb85a4ea0879dfa73cffb546 Mon Sep 17 00:00:00 2001 From: linkst <2024023709@m.scnu.edu.cn> Date: Fri, 31 Jul 2026 17:08:51 +0800 Subject: [PATCH 1/5] refactor(sandbox): extract unshare mapping probe into cached helper No behavior change. Move the inline probe out of unshare_user_namespace_works into a reusable unshare_probe helper and a cached working_unshare_mapping() that picks the first working candidate from UNSHARE_MAPPING_CANDIDATES, so the launcher and the capability probe share one code path. --- rust/crates/runtime/src/sandbox.rs | 38 ++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/rust/crates/runtime/src/sandbox.rs b/rust/crates/runtime/src/sandbox.rs index 2df08791e3..354cbfaced 100644 --- a/rust/crates/runtime/src/sandbox.rs +++ b/rust/crates/runtime/src/sandbox.rs @@ -282,6 +282,36 @@ fn command_exists(command: &str) -> bool { .is_some_and(|paths| env::split_paths(&paths).any(|path| path.join(command).exists())) } +/// Candidate `unshare` user-namespace mapping options, in preference order. +const UNSHARE_MAPPING_CANDIDATES: &[&[&str]] = &[&["--user", "--map-root-user"]]; + +/// Probe a candidate `unshare` mapping invocation with a trivial program. +fn unshare_probe(args: &[&str]) -> bool { + std::process::Command::new("unshare") + .args(args) + .arg("true") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +/// The first mapping option set that works on this machine, if any. +/// +/// Probes are cached for the process lifetime; a missing `unshare` binary or a +/// kernel that refuses every mapping yields `None`. +fn working_unshare_mapping() -> Option<&'static [&'static str]> { + use std::sync::OnceLock; + static MAPPING: OnceLock> = OnceLock::new(); + *MAPPING.get_or_init(|| { + UNSHARE_MAPPING_CANDIDATES + .iter() + .copied() + .find(|args| unshare_probe(args)) + }) +} + /// Check whether `unshare --user` actually works on this system. /// On some CI environments (e.g. GitHub Actions), the binary exists but /// user namespaces are restricted, causing silent failures. @@ -292,13 +322,7 @@ fn unshare_user_namespace_works() -> bool { if !command_exists("unshare") { return false; } - std::process::Command::new("unshare") - .args(["--user", "--map-root-user", "true"]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|status| status.success()) + working_unshare_mapping().is_some() }) } From 277fdda89433b28f451ed9acea3012541eafba2a Mon Sep 17 00:00:00 2001 From: linkst <2024023709@m.scnu.edu.cn> Date: Fri, 31 Jul 2026 17:09:00 +0800 Subject: [PATCH 2/5] fix(sandbox): fall back to --map-auto when root-user mapping is restricted Plain `unshare --user --map-root-user` fails on kernels and containers that block unprivileged writes to /proc/self/uid_map (e.g. GitHub Actions, restricted AppArmor profiles). On those systems util-linux delegates to the setuid newuidmap/newgidmap helpers when --map-auto is also present. Add the combined form as a fallback candidate and build the launcher args from the probed mapping, so systems without newuidmap/newgidmap or a /etc/subuid range keep using the plain form. --- rust/crates/runtime/src/sandbox.rs | 37 ++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/rust/crates/runtime/src/sandbox.rs b/rust/crates/runtime/src/sandbox.rs index 354cbfaced..4eaaaf360b 100644 --- a/rust/crates/runtime/src/sandbox.rs +++ b/rust/crates/runtime/src/sandbox.rs @@ -220,15 +220,18 @@ pub fn build_linux_sandbox_command( return None; } - let mut args = vec![ - "--user".to_string(), - "--map-root-user".to_string(), + let mut args: Vec = working_unshare_mapping() + .unwrap_or(UNSHARE_MAPPING_CANDIDATES[0]) + .iter() + .map(|arg| arg.to_string()) + .collect(); + args.extend([ "--mount".to_string(), "--ipc".to_string(), "--pid".to_string(), "--uts".to_string(), "--fork".to_string(), - ]; + ]); if status.network_active { args.push("--net".to_string()); } @@ -283,7 +286,16 @@ fn command_exists(command: &str) -> bool { } /// Candidate `unshare` user-namespace mapping options, in preference order. -const UNSHARE_MAPPING_CANDIDATES: &[&[&str]] = &[&["--user", "--map-root-user"]]; +/// +/// Most systems accept `--map-root-user` alone. On kernels or containers that +/// block unprivileged writes to `/proc/self/uid_map` (e.g. GitHub Actions, +/// restricted AppArmor profiles), util-linux instead delegates to the setuid +/// `newuidmap`/`newgidmap` helpers when `--map-auto` is also present; that +/// requires the current user to have a range in `/etc/subuid`/`/etc/subgid`. +const UNSHARE_MAPPING_CANDIDATES: &[&[&str]] = &[ + &["--user", "--map-root-user"], + &["--user", "--map-root-user", "--map-auto"], +]; /// Probe a candidate `unshare` mapping invocation with a trivial program. fn unshare_probe(args: &[&str]) -> bool { @@ -383,6 +395,21 @@ mod tests { assert_eq!(request.allowed_mounts, vec!["tmp"]); } + #[test] + fn mapping_candidates_prefer_plain_root_mapping() { + assert!(!super::UNSHARE_MAPPING_CANDIDATES.is_empty()); + for candidate in super::UNSHARE_MAPPING_CANDIDATES { + assert!(candidate.contains(&"--user")); + assert!(candidate.contains(&"--map-root-user")); + } + // The plain form must be tried first; `--map-auto` is only a fallback + // for kernels/containers that block unprivileged uid_map writes. + assert_eq!( + super::UNSHARE_MAPPING_CANDIDATES[0], + &["--user", "--map-root-user"] + ); + } + #[test] fn builds_linux_launcher_with_network_flag_when_requested() { let config = SandboxConfig::default(); From 9cbe6d9a8c4e40a2d57f787c0a1d006f00d83bc1 Mon Sep 17 00:00:00 2001 From: linkst <2024023709@m.scnu.edu.cn> Date: Sat, 1 Aug 2026 15:21:56 +0800 Subject: [PATCH 3/5] docs(sandbox): document newuidmap/newgidmap dependency for --map-auto fallback The fallback candidate relies on the setuid newuidmap/newgidmap helpers (uidmap package) plus a subuid/subgid range for the current user. Note in the candidate docs that the startup probe rejects the candidate when those are missing, so the plain --map-root-user form is used instead. --- rust/crates/runtime/src/sandbox.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rust/crates/runtime/src/sandbox.rs b/rust/crates/runtime/src/sandbox.rs index 4eaaaf360b..9bf5b0f215 100644 --- a/rust/crates/runtime/src/sandbox.rs +++ b/rust/crates/runtime/src/sandbox.rs @@ -290,8 +290,12 @@ fn command_exists(command: &str) -> bool { /// Most systems accept `--map-root-user` alone. On kernels or containers that /// block unprivileged writes to `/proc/self/uid_map` (e.g. GitHub Actions, /// restricted AppArmor profiles), util-linux instead delegates to the setuid -/// `newuidmap`/`newgidmap` helpers when `--map-auto` is also present; that -/// requires the current user to have a range in `/etc/subuid`/`/etc/subgid`. +/// `newuidmap`/`newgidmap` helpers when `--map-auto` is also present. +/// +/// That fallback therefore depends on the setuid helpers (the `uidmap` +/// package on Debian/Ubuntu) and on the current user having a range in +/// `/etc/subuid` and `/etc/subgid`. When either is missing, `--map-auto` +/// fails and the startup probe rejects the candidate, keeping the plain form. const UNSHARE_MAPPING_CANDIDATES: &[&[&str]] = &[ &["--user", "--map-root-user"], &["--user", "--map-root-user", "--map-auto"], From 5bcc43b0814bbdaac72314ffba6bdff5352d11f6 Mon Sep 17 00:00:00 2001 From: code-yeongyu Date: Thu, 6 Aug 2026 19:01:36 +0900 Subject: [PATCH 4/5] scratch: probe unshare semantics on runner (will be reverted) --- rust/crates/runtime/tests/probe_unshare.rs | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 rust/crates/runtime/tests/probe_unshare.rs diff --git a/rust/crates/runtime/tests/probe_unshare.rs b/rust/crates/runtime/tests/probe_unshare.rs new file mode 100644 index 0000000000..d4328207ef --- /dev/null +++ b/rust/crates/runtime/tests/probe_unshare.rs @@ -0,0 +1,50 @@ +//! Scratch probe: dump GitHub runner unshare semantics (temporary, PR will be closed). +#![cfg(target_os = "linux")] + +use std::process::Command; + +fn run(args: &[&str]) -> (i32, String, String) { + let out = Command::new("unshare").args(args).output(); + match out { + Ok(o) => ( + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stdout).trim().to_string(), + String::from_utf8_lossy(&o.stderr).trim().to_string(), + ), + Err(e) => (-1, String::new(), format!("spawn error: {e}")), + } +} + +#[test] +fn dump_unshare_semantics() { + let uid = unsafe { libc::getuid() }; + let mut report = String::new(); + report.push_str(&format!("uid={uid} euid={}\n", unsafe { libc::geteuid() })); + for f in ["/etc/subuid", "/etc/subgid"] { + report.push_str(&format!("--- {f} ---\n")); + if let Ok(s) = std::fs::read_to_string(f) { + report.push_str(&s); + } else { + report.push_str("(unreadable)\n"); + } + } + for k in ["/proc/sys/kernel/unprivileged_userns_clone", "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"] { + report.push_str(&format!("{k} = {}\n", std::fs::read_to_string(k).unwrap_or_else(|_| "(n/a)".into()))); + } + for (name, args) in [ + ("plain", &["--user", "--map-root-user", "true"][..]), + ("auto", &["--user", "--map-root-user", "--map-auto", "true"][..]), + ( + "plain-full", + &["--user", "--map-root-user", "--mount", "--ipc", "--pid", "--uts", "--fork", "sh", "-lc", "echo alpha"][..], + ), + ( + "auto-full", + &["--user", "--map-root-user", "--map-auto", "--mount", "--ipc", "--pid", "--uts", "--fork", "sh", "-lc", "echo alpha"][..], + ), + ] { + let (rc, so, se) = run(args); + report.push_str(&format!("[{name}] rc={rc} stdout={so:?} stderr={se:?}\n")); + } + panic!("PROBE REPORT:\n{report}"); +} From 39b3821cd5c7bf10471c20d17522761f06a7d814 Mon Sep 17 00:00:00 2001 From: code-yeongyu Date: Thu, 6 Aug 2026 19:03:27 +0900 Subject: [PATCH 5/5] scratch: fix probe to avoid unsafe --- rust/crates/runtime/tests/probe_unshare.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/rust/crates/runtime/tests/probe_unshare.rs b/rust/crates/runtime/tests/probe_unshare.rs index d4328207ef..072aceb03e 100644 --- a/rust/crates/runtime/tests/probe_unshare.rs +++ b/rust/crates/runtime/tests/probe_unshare.rs @@ -1,4 +1,4 @@ -//! Scratch probe: dump GitHub runner unshare semantics (temporary, PR will be closed). +//! Scratch probe: dump GitHub runner unshare semantics (temporary, PRs will be closed). #![cfg(target_os = "linux")] use std::process::Command; @@ -15,11 +15,18 @@ fn run(args: &[&str]) -> (i32, String, String) { } } +fn sh(cmd: &str) -> String { + Command::new("sh") + .args(["-lc", cmd]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() +} + #[test] fn dump_unshare_semantics() { - let uid = unsafe { libc::getuid() }; let mut report = String::new(); - report.push_str(&format!("uid={uid} euid={}\n", unsafe { libc::geteuid() })); + report.push_str(&format!("uid line: {}\n", sh("id"))); for f in ["/etc/subuid", "/etc/subgid"] { report.push_str(&format!("--- {f} ---\n")); if let Ok(s) = std::fs::read_to_string(f) { @@ -42,6 +49,10 @@ fn dump_unshare_semantics() { "auto-full", &["--user", "--map-root-user", "--map-auto", "--mount", "--ipc", "--pid", "--uts", "--fork", "sh", "-lc", "echo alpha"][..], ), + ( + "auto-full-echo-multi", + &["--user", "--map-root-user", "--map-auto", "--mount", "--ipc", "--pid", "--uts", "--fork", "sh", "-lc", "echo alpha from bash"][..], + ), ] { let (rc, so, se) = run(args); report.push_str(&format!("[{name}] rc={rc} stdout={so:?} stderr={se:?}\n"));