crates/heimdall-container/src/sys.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The few raw system calls heimdall-container needs (the only `unsafe`).
//! Heimdall is a defensive sandbox; each call here only reads the calling
//! process's own state, makes a pipe, moves an fd over a socket it holds, or
//! signals its own shim or job (learning runs, crate::learn).
#![allow(unsafe_code)]
/// The kernel's Landlock ABI (`landlock_create_ruleset(NULL, 0, VERSION)`),
/// `None` when Landlock is not built in or not enabled.
pub fn landlock_abi() -> Option<u32> {
const LANDLOCK_CREATE_RULESET_VERSION: libc::c_ulong = 1;
// SAFETY: with a NULL attr and size 0 the kernel only returns the ABI
// version; no memory is read or written.
let v = unsafe {
libc::syscall(
libc::SYS_landlock_create_ruleset,
std::ptr::null::<libc::c_void>(),
0usize,
LANDLOCK_CREATE_RULESET_VERSION,
)
};
(v > 0).then_some(v as u32)
}
/// `prctl(PR_GET_NO_NEW_PRIVS)` of the calling thread.
#[cfg_attr(not(feature = "youki"), allow(dead_code))]
pub fn no_new_privs() -> bool {
// SAFETY: PR_GET_NO_NEW_PRIVS takes no pointers.
unsafe { libc::prctl(libc::PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) == 1 }
}
/// `prctl(PR_GET_SECCOMP)` of the calling thread: 0 off, 2 filter.
#[cfg_attr(not(feature = "youki"), allow(dead_code))]
pub fn seccomp_mode() -> i32 {
// SAFETY: PR_GET_SECCOMP takes no pointers.
unsafe { libc::prctl(libc::PR_GET_SECCOMP, 0, 0, 0, 0) }
}
/// A close-on-exec pipe: (read end, write end).
#[cfg(feature = "youki")]
pub fn pipe_cloexec() -> std::io::Result<(std::os::fd::OwnedFd, std::os::fd::OwnedFd)> {
use std::os::fd::FromRawFd;
let mut fds = [0 as libc::c_int; 2];
// SAFETY: `fds` is a valid array of two ints for the kernel to fill.
if unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
return Err(std::io::Error::last_os_error());
}
// SAFETY: both fds were just created and are owned by nobody else.
Ok(unsafe {
(
std::os::fd::OwnedFd::from_raw_fd(fds[0]),
std::os::fd::OwnedFd::from_raw_fd(fds[1]),
)
})
}
/// Write all of `buf` to a raw fd this process inherited (the executor's
/// report pipe; the fd is not owned here and stays open).
#[cfg(feature = "youki")]
pub fn write_fd(fd: i32, mut buf: &[u8]) -> std::io::Result<()> {
while !buf.is_empty() {
// SAFETY: `buf` is a valid readable slice of `buf.len()` bytes.
let n = unsafe { libc::write(fd, buf.as_ptr().cast(), buf.len()) };
if n < 0 {
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(e);
}
buf = &buf[n as usize..];
}
Ok(())
}
/// Set `O_NONBLOCK` on a raw fd this process owns.
#[cfg(feature = "youki")]
pub fn set_nonblocking(fd: i32) -> std::io::Result<()> {
// SAFETY: F_GETFL / F_SETFL take no pointers.
unsafe {
let fl = libc::fcntl(fd, libc::F_GETFL);
if fl < 0 || libc::fcntl(fd, libc::F_SETFL, fl | libc::O_NONBLOCK) < 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
/// `dup3(fd, target, O_CLOEXEC)`: put an inherited fd at a fixed number
/// (the executor's report fd, crate::genprof::REPORT_FD).
#[cfg(feature = "youki")]
pub fn dup_cloexec_to(fd: i32, target: i32) -> std::io::Result<()> {
// SAFETY: dup3 takes no pointers; `target` is replaced if open.
if unsafe { libc::dup3(fd, target, libc::O_CLOEXEC) } < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// `_exit(code)`: leave without running anything else (after the generated
/// filter is installed, only `exit_group` is sure to be allowed).
#[cfg(feature = "youki")]
pub fn exit_now(code: i32) -> ! {
// SAFETY: _exit never returns and touches no memory of ours.
unsafe { libc::_exit(code) }
}
/// `kill(pid, sig)`: the runner forwards a learning run's signals to its shim.
pub fn kill(pid: i32, sig: i32) -> std::io::Result<()> {
// SAFETY: kill takes no pointers.
if unsafe { libc::kill(pid, sig) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// The pid of a connected unix socket's peer (`SO_PEERCRED`).
pub fn peer_pid(fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<i32> {
use std::os::fd::AsRawFd;
let mut cred = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: getsockopt writes at most `len` bytes into `cred`.
let r = unsafe {
libc::getsockopt(
fd.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
(&mut cred as *mut libc::ucred).cast(),
&mut len,
)
};
if r != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(cred.pid)
}
/// Signals a learning shim caught (bit `1 << signo`), not yet forwarded.
#[cfg(feature = "youki")]
static CAUGHT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(feature = "youki")]
extern "C" fn caught(sig: libc::c_int) {
if (0..64).contains(&sig) {
CAUGHT.fetch_or(1u64 << sig, std::sync::atomic::Ordering::SeqCst);
}
}
/// Catch `sigs` into a set the shim drains with [`take_signals`] (a learning
/// run forwards them to its program instead of dying from them).
#[cfg(feature = "youki")]
pub fn catch_signals(sigs: &[i32]) {
for &s in sigs {
// SAFETY: the handler only does an atomic fetch_or (async-signal-safe).
unsafe { libc::signal(s, caught as *const () as libc::sighandler_t) };
}
}
/// The signals caught since the last call.
#[cfg(feature = "youki")]
pub fn take_signals() -> u64 {
CAUGHT.swap(0, std::sync::atomic::Ordering::SeqCst)
}
/// Send `fd` over the unix socket at the raw fd `sock` (the executor's
/// hand-off fd, heimdall-seccomp's `HANDOFF_FD`, which this process holds).
#[cfg(feature = "youki")]
pub fn send_fd_at(sock: i32, fd: std::os::fd::BorrowedFd<'_>) -> std::io::Result<()> {
// SAFETY: `sock` was dup3'd to this number by the caller and stays open
// for the duration of the call.
let s = unsafe { std::os::fd::BorrowedFd::borrow_raw(sock) };
heimdall_seccomp::notif::send_fd(s, fd)
}
/// Move the calling (single-threaded) process into a new, NESTED user
/// namespace where its uid and gid (root of the job's namespace, the
/// runner's uid on the host) are `id`: a learning run's program that must not
/// run as root (crate::learn::LearnPlan::uid). The maps are written by a
/// short-lived child that stays in the job's namespace (the owner of the new
/// one, so it may map it; the caller itself may be denied every capability
/// in its new namespace, e.g. by Ubuntu's
/// `apparmor_restrict_unprivileged_userns`). Nothing gains a capability: the
/// only mapping is the caller's own id, and `setgroups` is denied.
#[cfg(feature = "youki")]
pub fn become_uid(id: u32) -> std::io::Result<()> {
use std::io::{Error, Read, Write};
let (go_r, go_w) = pipe_cloexec()?;
// SAFETY: getpid takes no pointers.
let me = unsafe { libc::getpid() };
// SAFETY: the job's init is single-threaded here (the executor, before
// execve); the child only writes three /proc files and _exits.
let child = unsafe { libc::fork() };
if child < 0 {
return Err(Error::last_os_error());
}
if child == 0 {
drop(go_w);
let mut b = [0u8; 1];
let go = std::fs::File::from(go_r).read(&mut b).is_ok_and(|n| n == 1) && b[0] == 1;
let mut code = 0;
if go {
// The exit code names the step (1..=3, x 64) and the errno (< 64).
let steps = [
("setgroups", "deny".to_string()),
("uid_map", format!("{id} 0 1")),
("gid_map", format!("{id} 0 1")),
];
for (i, (f, v)) in steps.iter().enumerate() {
if let Err(e) = std::fs::write(format!("/proc/{me}/{f}"), v) {
code = (i as i32 + 1) * 64 + e.raw_os_error().unwrap_or(63).clamp(1, 63);
break;
}
}
}
exit_now(code)
}
drop(go_r);
// SAFETY: unshare takes no pointers.
let unshared = unsafe { libc::unshare(libc::CLONE_NEWUSER) };
let unshare_err = Error::last_os_error();
let _ = std::fs::File::from(go_w).write_all(&[u8::from(unshared == 0)]);
let mut st: libc::c_int = 0;
// SAFETY: waitpid on our own child, `st` is a valid int.
while unsafe { libc::waitpid(child, &mut st, 0) } < 0 {
let e = Error::last_os_error();
if e.kind() != std::io::ErrorKind::Interrupted {
return Err(e);
}
}
if unshared != 0 {
return Err(Error::other(format!(
"unshare(CLONE_NEWUSER): {unshare_err}"
)));
}
if !libc::WIFEXITED(st) || libc::WEXITSTATUS(st) != 0 {
let code = if libc::WIFEXITED(st) {
libc::WEXITSTATUS(st)
} else {
0
};
let step = ["?", "setgroups", "uid_map", "gid_map"]
[usize::try_from(code / 64).unwrap_or(0).min(3)];
return Err(Error::other(format!(
"writing {step}: {}",
Error::from_raw_os_error(code % 64)
)));
}
drop_all_caps()?;
// SAFETY: getuid / getgid take no pointers.
let (u, g) = unsafe { (libc::getuid(), libc::getgid()) };
if (u, g) != (id, id) {
return Err(Error::other(format!(
"uid {u} gid {g} after the maps, not {id}"
)));
}
Ok(())
}
/// Drop every capability of the calling thread (bounding, effective,
/// permitted, inheritable; ambient cleared).
#[cfg(feature = "youki")]
pub fn drop_all_caps() -> std::io::Result<()> {
#[repr(C)]
struct Header {
version: u32,
pid: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct Data {
effective: u32,
permitted: u32,
inheritable: u32,
}
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
let mut h = Header {
version: LINUX_CAPABILITY_VERSION_3,
pid: 0,
};
let d = [Data {
effective: 0,
permitted: 0,
inheritable: 0,
}; 2];
// SAFETY: prctl takes no pointers; capset reads a valid header and two
// data structs (version 3).
unsafe {
// A new user namespace starts with a full bounding set: empty it
// (needs CAP_SETPCAP there, which the namespace's creator has).
for cap in 0..64 {
if libc::prctl(libc::PR_CAPBSET_DROP, cap, 0, 0, 0) != 0 {
let e = std::io::Error::last_os_error();
if e.raw_os_error() == Some(libc::EINVAL) {
break; // past the kernel's last capability
}
return Err(std::io::Error::other(format!("bounding set: {e}")));
}
}
libc::prctl(
libc::PR_CAP_AMBIENT,
libc::PR_CAP_AMBIENT_CLEAR_ALL,
0,
0,
0,
);
if libc::syscall(libc::SYS_capset, &mut h as *mut Header, d.as_ptr()) != 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}