#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxError {
UserNamespace,
SetGroups,
UidMap,
GidMap,
NetworkNamespace,
}
impl SandboxError {
fn from_raw(code: i32) -> Option<SandboxError> {
Some(match code {
1 => SandboxError::UserNamespace,
2 => SandboxError::SetGroups,
3 => SandboxError::UidMap,
4 => SandboxError::GidMap,
5 => SandboxError::NetworkNamespace,
_ => return None,
})
}
pub fn explain(self) -> &'static str {
match self {
SandboxError::UserNamespace => {
"this kernel refused to create a user namespace. Unprivileged user namespaces \
may be disabled (`kernel.unprivileged_userns_clone=0`, or an AppArmor/Docker \
profile). Without one, an ordinary user cannot make a network namespace and \
the build cannot be made hermetic."
}
SandboxError::SetGroups => {
"writing `deny` to /proc/self/setgroups failed, so no uid map may be written"
}
SandboxError::UidMap => "writing /proc/self/uid_map failed",
SandboxError::GidMap => "writing /proc/self/gid_map failed",
SandboxError::NetworkNamespace => {
"this kernel refused to create a network namespace inside the user namespace"
}
}
}
}
pub const SANDBOX_EXIT_BASE: i32 = 120;
pub fn exit_code(error: SandboxError) -> i32 {
SANDBOX_EXIT_BASE
+ match error {
SandboxError::UserNamespace => 1,
SandboxError::SetGroups => 2,
SandboxError::UidMap => 3,
SandboxError::GidMap => 4,
SandboxError::NetworkNamespace => 5,
}
}
pub fn leave_child(code: i32) -> ! {
extern "C" {
fn _exit(status: i32) -> !;
}
unsafe { _exit(code) }
}
pub fn error_from_exit(code: i32) -> Option<SandboxError> {
if !(SANDBOX_EXIT_BASE..SANDBOX_EXIT_BASE + 16).contains(&code) {
return None;
}
SandboxError::from_raw(code - SANDBOX_EXIT_BASE)
}
fn write_proc(path: &str, bytes: &[u8]) -> bool {
let Ok(fd) = rustix::fs::open(path, rustix::fs::OFlags::WRONLY, rustix::fs::Mode::empty())
else {
return false;
};
matches!(rustix::io::write(&fd, bytes), Ok(n) if n == bytes.len())
}
fn render(value: u32, buf: &mut [u8; 12]) -> &[u8] {
if value == 0 {
buf[0] = b'0';
return &buf[..1];
}
let mut at = buf.len();
let mut value = value;
while value > 0 {
at -= 1;
buf[at] = b'0' + (value % 10) as u8;
value /= 10;
}
let len = buf.len() - at;
buf.copy_within(at.., 0);
&buf[..len]
}
pub unsafe fn enter() -> Result<(), SandboxError> {
use rustix::thread::UnshareFlags;
let uid = rustix::process::geteuid().as_raw();
let gid = rustix::process::getegid().as_raw();
if unsafe { rustix::thread::unshare_unsafe(UnshareFlags::NEWUSER) }.is_err() {
return Err(SandboxError::UserNamespace);
}
if !write_proc("/proc/self/setgroups", b"deny") {
return Err(SandboxError::SetGroups);
}
let mut map = [0u8; 32];
let mut digits = [0u8; 12];
let mut at = 0usize;
map[at] = b'0';
at += 1;
map[at] = b' ';
at += 1;
let rendered = render(uid, &mut digits);
map[at..at + rendered.len()].copy_from_slice(rendered);
at += rendered.len();
map[at..at + 2].copy_from_slice(b" 1");
at += 2;
if !write_proc("/proc/self/uid_map", &map[..at]) {
return Err(SandboxError::UidMap);
}
let mut at = 0usize;
map[at] = b'0';
at += 1;
map[at] = b' ';
at += 1;
let rendered = render(gid, &mut digits);
map[at..at + rendered.len()].copy_from_slice(rendered);
at += rendered.len();
map[at..at + 2].copy_from_slice(b" 1");
at += 2;
if !write_proc("/proc/self/gid_map", &map[..at]) {
return Err(SandboxError::GidMap);
}
if unsafe { rustix::thread::unshare_unsafe(UnshareFlags::NEWNET) }.is_err() {
return Err(SandboxError::NetworkNamespace);
}
Ok(())
}
pub fn available() -> Result<(), SandboxError> {
use std::os::unix::process::CommandExt as _;
let mut command = std::process::Command::new("true");
let enter_sandbox = || {
if let Err(err) = unsafe { enter() } {
leave_child(exit_code(err));
}
Ok(())
};
unsafe {
command.pre_exec(enter_sandbox);
}
let status = command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let Ok(status) = status else {
return Ok(());
};
match status.code().and_then(error_from_exit) {
Some(err) => Err(err),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pre_exec_hook_that_fails_leaves_at_once_even_when_an_atexit_handler_would_block() {
use std::os::unix::process::CommandExt as _;
use std::sync::atomic::{AtomicI32, Ordering};
static PARENT: AtomicI32 = AtomicI32::new(0);
extern "C" fn blocks_in_a_child() {
if std::process::id() as i32 != PARENT.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_secs(3));
}
}
extern "C" {
fn atexit(f: extern "C" fn()) -> i32;
}
PARENT.store(std::process::id() as i32, Ordering::SeqCst);
assert_eq!(unsafe { atexit(blocks_in_a_child) }, 0);
let timed = |leave: fn(i32) -> !| -> (std::time::Duration, Option<i32>) {
let mut c = std::process::Command::new("true");
unsafe {
c.pre_exec(move || leave(97));
}
let t0 = std::time::Instant::now();
let st = c.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().expect("spawned");
(t0.elapsed(), st.code())
};
let (held, code) = timed(|c| std::process::exit(c));
assert!(held >= std::time::Duration::from_millis(2500), "the control: exit(3) in the forked child ran the stalling handler (held {held:?}, code {code:?})");
let (held, code) = timed(leave_child);
assert_eq!(code, Some(97), "leave_child exits with its code");
assert!(held < std::time::Duration::from_millis(2500), "leave_child ran an atexit handler: held {held:?}");
}
#[test]
fn the_exit_codes_round_trip_and_do_not_collide_with_ordinary_ones() {
for err in [
SandboxError::UserNamespace,
SandboxError::SetGroups,
SandboxError::UidMap,
SandboxError::GidMap,
SandboxError::NetworkNamespace,
] {
assert_eq!(error_from_exit(exit_code(err)), Some(err));
}
for ordinary in [0, 1, 2, 101, 119] {
assert_eq!(error_from_exit(ordinary), None, "code {ordinary}");
}
}
#[test]
fn the_decimal_renderer_agrees_with_the_formatter() {
for value in [0u32, 1, 9, 10, 99, 1000, 65534, u32::MAX] {
let mut buf = [0u8; 12];
assert_eq!(
std::str::from_utf8(render(value, &mut buf)).unwrap(),
value.to_string(),
"rendering {value}"
);
}
}
}
#[cfg(test)]
mod live_tests {
use super::*;
use std::io::Read as _;
use std::os::unix::process::CommandExt as _;
fn run(script: &str, sandboxed: bool) -> Option<bool> {
let mut command = std::process::Command::new("bash");
command.arg("-c").arg(script);
if sandboxed {
unsafe {
command.pre_exec(|| {
if let Err(err) = enter() {
leave_child(exit_code(err));
}
Ok(())
});
}
}
let status = command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.ok()?;
if status.code().and_then(error_from_exit).is_some() {
return None;
}
Some(status.success())
}
#[test]
fn a_build_in_the_sandbox_cannot_reach_a_listener_the_host_can() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("addr").port();
std::thread::spawn(move || {
for mut stream in listener.incoming().take(4).flatten() {
let mut sink = [0u8; 1];
let _ = stream.read(&mut sink);
}
});
let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
let Some(outside) = run(&script, false) else {
unreachable!("the unsandboxed arm cannot report a sandbox failure")
};
assert!(
outside,
"the control failed: bash could not reach a listener on loopback without any \
sandbox, so this test cannot tell a cut network from a broken script"
);
match run(&script, true) {
None => eprintln!(
"skipped: this kernel does not allow unprivileged user namespaces, so the \
sandbox cannot be exercised here. `gunnar runner` refuses to build rather \
than building without one."
),
Some(inside) => assert!(
!inside,
"a process inside the build sandbox reached a listener on the host's loopback. \
The network namespace is not being entered, and every `network: false` in a \
build record this server signs is a lie."
),
}
}
#[test]
fn a_build_in_the_sandbox_still_owns_the_files_it_writes() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("written");
let script = format!("echo hello > {}", path.display());
match run(&script, true) {
None => eprintln!("skipped: no unprivileged user namespaces on this kernel"),
Some(ok) => {
assert!(
ok,
"a build that only writes a file failed inside the sandbox"
);
assert_eq!(
std::fs::read_to_string(&path).expect("read it back"),
"hello\n",
"the sandboxed build could not write to the directory it was given. The uid \
map is wrong: inside the namespace it must be root and outside it must \
still be the same unprivileged user."
);
}
}
}
}