use std::path::{Path, PathBuf};
pub const SEALED_APPARMOR_PROFILE_NAME: &str = "mur-sealed";
pub const SEALED_APPARMOR_PROFILE_PATH: &str = "/etc/apparmor.d/mur-sealed";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SealedBlocker {
NotLinux,
AppArmorProfileMissing,
NamespaceCreationDenied,
IdMapDenied,
MountDenied,
KernelUnsupported,
LandlockUnavailable,
}
impl SealedBlocker {
pub const ALL: &'static [SealedBlocker] = &[
SealedBlocker::NotLinux,
SealedBlocker::AppArmorProfileMissing,
SealedBlocker::NamespaceCreationDenied,
SealedBlocker::IdMapDenied,
SealedBlocker::MountDenied,
SealedBlocker::KernelUnsupported,
SealedBlocker::LandlockUnavailable,
];
#[must_use]
pub fn reason(self) -> String {
match self {
SealedBlocker::NotLinux => {
"sealed requires a Linux mount namespace + pivot_root; this \
platform has no such primitive and never will (macOS and every non-Linux target \
stay at advisory permanently)"
.to_string()
}
SealedBlocker::AppArmorProfileMissing => format!(
"sealed requires an unprivileged user+mount namespace, and AppArmor's \
unprivileged-userns restriction is active on this host while the '{name}' profile \
is not confining this binary. Install and load the profile shipped with mur: \
`sudo install -m 644 packaging/apparmor/{name} {path} && sudo apparmor_parser -r \
{path}` (or re-run the mur installer as root), then re-run. To turn the \
restriction off host-wide instead: `sudo sysctl -w \
kernel.apparmor_restrict_unprivileged_userns=0`.",
name = SEALED_APPARMOR_PROFILE_NAME,
path = SEALED_APPARMOR_PROFILE_PATH,
),
SealedBlocker::NamespaceCreationDenied => {
"sealed requires unshare(CLONE_NEWUSER | CLONE_NEWNS), which this host refused. \
This is the usual answer inside a container: CAP_SYS_ADMIN is absent, or the \
container's own seccomp filter blocks unshare(2). Either add `--cap-add \
SYS_ADMIN` to the container invocation, or establish the mount namespace outside \
the container and run mur inside it. The runtime will not fall back to a weaker \
class."
.to_string()
}
SealedBlocker::IdMapDenied => {
"sealed created a user+mount namespace, but writing the identity uid_map/gid_map \
into it was refused, so the process cannot own the namespace it just made. This \
is an id-mapping problem, not a missing capability: check that \
/proc/sys/user/max_user_namespaces is non-zero, that no LSM policy blocks \
uid_map writes for this binary, and that mur is not already running inside an \
unmapped user namespace. The runtime will not fall back to a weaker class."
.to_string()
}
SealedBlocker::MountDenied => format!(
"sealed created a user+mount namespace but mount(2) inside it was refused, which \
is what a confinement that permits userns_create and then denies CAP_SYS_ADMIN \
looks like. On an AppArmor host, load the shipped profile: `sudo apparmor_parser \
-r {path}`. Inside a container, add `--cap-add SYS_ADMIN` to the container \
invocation, or establish the mount namespace outside the container.",
path = SEALED_APPARMOR_PROFILE_PATH,
),
SealedBlocker::KernelUnsupported => {
"sealed requires unprivileged user namespaces, which this kernel does not provide \
(CONFIG_USER_NS=n, or user.max_user_namespaces=0). Raise \
`sudo sysctl -w user.max_user_namespaces=10000` if the sysctl is merely zeroed, \
otherwise run on a kernel built with CONFIG_USER_NS=y."
.to_string()
}
SealedBlocker::LandlockUnavailable => {
"sealed keeps Landlock and seccomp inside the composed root as defence in depth, \
and this host provides no usable Landlock ABI (Linux 5.13+ required). A host that \
cannot back scoped cannot back sealed either."
.to_string()
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum NamespaceProbe {
Ok,
Denied,
MapDenied,
MountDenied,
#[default]
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct SealedProbe {
pub(crate) apparmor_permits_userns: bool,
pub(crate) namespace: NamespaceProbe,
}
pub(crate) fn sealed_blocker(
is_linux: bool,
landlock_fully_enforced: bool,
probe: SealedProbe,
) -> Option<SealedBlocker> {
if !is_linux {
return Some(SealedBlocker::NotLinux);
}
if !probe.apparmor_permits_userns {
return Some(SealedBlocker::AppArmorProfileMissing);
}
match probe.namespace {
NamespaceProbe::Ok => {
if landlock_fully_enforced {
None
} else {
Some(SealedBlocker::LandlockUnavailable)
}
}
NamespaceProbe::Denied => Some(SealedBlocker::NamespaceCreationDenied),
NamespaceProbe::MapDenied => Some(SealedBlocker::IdMapDenied),
NamespaceProbe::MountDenied => Some(SealedBlocker::MountDenied),
NamespaceProbe::Unsupported => Some(SealedBlocker::KernelUnsupported),
}
}
pub const SEALED_RUNTIME_PATHS: &[&str] = &[
"/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SealedEtcPath {
pub path: &'static str,
pub directory: bool,
pub synthetic: Option<SyntheticEtcFile>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyntheticEtcFile {
Passwd,
Group,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SealedAccountIdentity<'a> {
pub(crate) uid: u32,
pub(crate) gid: u32,
pub(crate) home: &'a str,
}
const SYNTHETIC_LOGIN_SHELL: &str = "/bin/sh";
const SYNTHETIC_ACCOUNT_NAME: &str = "capsule";
impl SyntheticEtcFile {
pub(crate) fn file_name(self) -> &'static str {
match self {
Self::Passwd => "passwd",
Self::Group => "group",
}
}
pub(crate) fn render(self, identity: &SealedAccountIdentity) -> Result<String, String> {
let SealedAccountIdentity { uid, gid, home } = *identity;
match self {
Self::Passwd => {
if home.contains(':') || home.contains('\n') {
return Err(format!(
"sealed: the synthetic HOME {home} contains a character /etc/passwd cannot \
quote (':' or a newline), so no valid passwd entry can name it"
));
}
if uid == 0 {
return Ok(format!(
"root:x:0:{gid}:root:{home}:{SYNTHETIC_LOGIN_SHELL}\n"
));
}
Ok(format!(
"root:x:0:0:root:/root:{SYNTHETIC_LOGIN_SHELL}\n\
{SYNTHETIC_ACCOUNT_NAME}:x:{uid}:{gid}:Murmur capsule:{home}:\
{SYNTHETIC_LOGIN_SHELL}\n"
))
}
Self::Group => {
if gid == 0 {
return Ok("root:x:0:\n".to_string());
}
Ok(format!("root:x:0:\n{SYNTHETIC_ACCOUNT_NAME}:x:{gid}:\n"))
}
}
}
}
pub const SEALED_ETC_PATHS: &[SealedEtcPath] = &[
SealedEtcPath {
path: "/etc/ld.so.cache",
directory: false,
synthetic: None,
},
SealedEtcPath {
path: "/etc/ld.so.conf",
directory: false,
synthetic: None,
},
SealedEtcPath {
path: "/etc/ld.so.conf.d",
directory: true,
synthetic: None,
},
SealedEtcPath {
path: "/etc/alternatives",
directory: true,
synthetic: None,
},
SealedEtcPath {
path: "/etc/ssl",
directory: true,
synthetic: None,
},
SealedEtcPath {
path: "/etc/pki",
directory: true,
synthetic: None,
},
SealedEtcPath {
path: "/etc/ca-certificates",
directory: true,
synthetic: None,
},
SealedEtcPath {
path: "/etc/ca-certificates.conf",
directory: false,
synthetic: None,
},
SealedEtcPath {
path: "/etc/resolv.conf",
directory: false,
synthetic: None,
},
SealedEtcPath {
path: "/etc/hosts",
directory: false,
synthetic: None,
},
SealedEtcPath {
path: "/etc/nsswitch.conf",
directory: false,
synthetic: None,
},
SealedEtcPath {
path: "/etc/localtime",
directory: false,
synthetic: None,
},
SealedEtcPath {
path: "/etc/timezone",
directory: false,
synthetic: None,
},
SealedEtcPath {
path: "/etc/terminfo",
directory: true,
synthetic: None,
},
SealedEtcPath {
path: "/etc/passwd",
directory: false,
synthetic: Some(SyntheticEtcFile::Passwd),
},
SealedEtcPath {
path: "/etc/group",
directory: false,
synthetic: Some(SyntheticEtcFile::Group),
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SealedDevice {
pub name: &'static str,
pub path: &'static str,
pub major: u32,
pub minor: u32,
pub mode: u32,
pub writable: bool,
}
pub const SEALED_DEVICE_NODES: &[SealedDevice] = &[
SealedDevice {
name: "null",
path: "/dev/null",
major: 1,
minor: 3,
mode: 0o666,
writable: true,
},
SealedDevice {
name: "zero",
path: "/dev/zero",
major: 1,
minor: 5,
mode: 0o666,
writable: true,
},
SealedDevice {
name: "full",
path: "/dev/full",
major: 1,
minor: 7,
mode: 0o666,
writable: true,
},
SealedDevice {
name: "random",
path: "/dev/random",
major: 1,
minor: 8,
mode: 0o666,
writable: false,
},
SealedDevice {
name: "urandom",
path: "/dev/urandom",
major: 1,
minor: 9,
mode: 0o666,
writable: false,
},
SealedDevice {
name: "tty",
path: "/dev/tty",
major: 5,
minor: 0,
mode: 0o666,
writable: true,
},
];
pub const SEALED_DEVICE_SYMLINKS: &[(&str, &str)] = &[
("fd", "/proc/self/fd"),
("stdin", "/proc/self/fd/0"),
("stdout", "/proc/self/fd/1"),
("stderr", "/proc/self/fd/2"),
("ptmx", "pts/ptmx"),
];
pub const SEALED_TMP_DIR_NAME: &str = ".mur-tmp";
pub const SEALED_ETC_STAGING_DIR_NAME: &str = ".mur-etc";
pub(crate) fn synthetic_etc_source(workdir: &Path, file: SyntheticEtcFile) -> PathBuf {
workdir
.join(SEALED_ETC_STAGING_DIR_NAME)
.join(file.file_name())
}
const OLD_ROOT_NAME: &str = ".mur-oldroot";
pub const SEALED_ROOT_BASE_CANDIDATES: &[&str] = &["/tmp", "/run", "/var/tmp", "/mnt", "/media"];
const PROC_HIDEPID_OPTIONS: &[&str] = &["hidepid=2", "hidepid=invisible", ""];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PathKind {
Dir,
File,
Symlink(PathBuf),
}
pub(crate) trait HostLayout {
fn kind(&self, path: &Path) -> Option<PathKind>;
}
pub(crate) struct RealHostLayout;
impl HostLayout for RealHostLayout {
fn kind(&self, path: &Path) -> Option<PathKind> {
let meta = std::fs::symlink_metadata(path).ok()?;
if meta.file_type().is_symlink() {
return Some(PathKind::Symlink(std::fs::read_link(path).ok()?));
}
if meta.is_dir() {
return Some(PathKind::Dir);
}
Some(PathKind::File)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RootOp {
MkDir(PathBuf),
MkFile(PathBuf),
Symlink { target: PathBuf, link: PathBuf },
Bind {
source: PathBuf,
target: PathBuf,
read_only: bool,
},
Tmpfs {
target: PathBuf,
options: &'static str,
},
Proc { target: PathBuf },
DevPts { target: PathBuf },
RemountReadOnly(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RootStep {
pub(crate) op: RootOp,
pub(crate) required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ComposedRootPlan {
pub(crate) base: PathBuf,
pub(crate) steps: Vec<RootStep>,
pub(crate) workdir_in_root: PathBuf,
}
pub(crate) fn choose_root_base(
workdir: &Path,
candidates: &[&str],
exists: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
candidates
.iter()
.map(Path::new)
.find(|candidate| !workdir.starts_with(candidate) && exists(candidate))
.map(Path::to_path_buf)
}
pub(crate) fn plan_composed_root(
workdir: &Path,
base: &Path,
extra_read_only: &[PathBuf],
staged_runtime_read_only: &[PathBuf],
host: &dyn HostLayout,
) -> ComposedRootPlan {
let mut builder = PlanBuilder::new(base);
for path in staged_runtime_read_only {
builder.require_bind(path);
}
for path in SEALED_RUNTIME_PATHS
.iter()
.map(Path::new)
.chain(extra_read_only.iter().map(PathBuf::as_path))
{
builder.mirror(path, host, false);
}
for entry in SEALED_ETC_PATHS {
let path = Path::new(entry.path);
match entry.synthetic {
None => builder.mirror(path, host, false),
Some(file) => builder.bind_file(
synthetic_etc_source(workdir, file),
rebase(base, path),
true,
),
}
}
let dev = base.join("dev");
builder.mkdir_p(&dev);
builder.push(
RootOp::Tmpfs {
target: dev.clone(),
options: "mode=0755,size=1m",
},
true,
);
for device in SEALED_DEVICE_NODES {
let source = PathBuf::from(device.path);
if host.kind(&source).is_none() {
continue;
}
let target = dev.join(device.name);
builder.push(RootOp::MkFile(target.clone()), false);
builder.push(
RootOp::Bind {
source,
target,
read_only: false,
},
false,
);
}
let pts = dev.join("pts");
builder.push(RootOp::MkDir(pts.clone()), false);
builder.push(RootOp::DevPts { target: pts }, false);
for (link, target) in SEALED_DEVICE_SYMLINKS {
builder.push(
RootOp::Symlink {
target: PathBuf::from(*target),
link: dev.join(link),
},
false,
);
}
builder.push(RootOp::RemountReadOnly(dev), false);
let proc = base.join("proc");
builder.push(RootOp::MkDir(proc.clone()), true);
builder.push(RootOp::Proc { target: proc }, true);
let tmp = base.join("tmp");
builder.push(RootOp::MkDir(tmp.clone()), true);
builder.push(
RootOp::Bind {
source: workdir.join(SEALED_TMP_DIR_NAME),
target: tmp,
read_only: false,
},
true,
);
let workdir_target = rebase(base, workdir);
builder.mkdir_p(&workdir_target);
builder.push(
RootOp::Bind {
source: workdir.to_path_buf(),
target: workdir_target,
read_only: false,
},
true,
);
ComposedRootPlan {
base: base.to_path_buf(),
steps: builder.steps,
workdir_in_root: workdir.to_path_buf(),
}
}
pub(crate) fn rebase(base: &Path, path: &Path) -> PathBuf {
let relative = path.strip_prefix("/").unwrap_or(path);
base.join(relative)
}
struct PlanBuilder {
base: PathBuf,
steps: Vec<RootStep>,
made: std::collections::HashSet<PathBuf>,
}
impl PlanBuilder {
fn new(base: &Path) -> Self {
let mut made = std::collections::HashSet::new();
made.insert(base.to_path_buf());
Self {
base: base.to_path_buf(),
steps: Vec::new(),
made,
}
}
fn push(&mut self, op: RootOp, required: bool) {
self.steps.push(RootStep { op, required });
}
fn mkdir_p(&mut self, target: &Path) {
let mut current = self.base.clone();
let Ok(relative) = target.strip_prefix(&self.base) else {
return;
};
for component in relative.components() {
current = current.join(component);
if self.made.insert(current.clone()) {
self.steps.push(RootStep {
op: RootOp::MkDir(current.clone()),
required: true,
});
}
}
}
fn require_bind(&mut self, source: &Path) {
let target = rebase(&self.base, source);
if self.made.contains(&target) {
return;
}
self.mkdir_p(&target);
self.push(
RootOp::Bind {
source: source.to_path_buf(),
target,
read_only: true,
},
true,
);
}
fn mirror(&mut self, source: &Path, host: &dyn HostLayout, required: bool) {
let Some(kind) = host.kind(source) else {
return;
};
let target = rebase(&self.base, source);
if self.made.contains(&target) {
return;
}
match kind {
PathKind::Symlink(link_target) => {
if let Some(parent) = target.parent() {
self.mkdir_p(parent);
}
self.made.insert(target.clone());
self.push(
RootOp::Symlink {
target: link_target,
link: target,
},
required,
);
}
PathKind::Dir => {
self.mkdir_p(&target);
self.push(
RootOp::Bind {
source: source.to_path_buf(),
target,
read_only: true,
},
required,
);
}
PathKind::File => self.bind_file(source.to_path_buf(), target, required),
}
}
fn bind_file(&mut self, source: PathBuf, target: PathBuf, required: bool) {
if self.made.contains(&target) {
return;
}
if let Some(parent) = target.parent() {
self.mkdir_p(parent);
}
self.made.insert(target.clone());
self.push(RootOp::MkFile(target.clone()), required);
self.push(
RootOp::Bind {
source,
target,
read_only: true,
},
required,
);
}
}
pub(crate) const SEALED_ROOT_FAILURE_PREFIX: &str = "sealed-root:";
#[cfg(target_os = "linux")]
pub(crate) use linux::{
build_sealed_root_spec, construct_composed_root, probe_sealed_support, SealedRootSpec,
};
#[cfg(target_os = "linux")]
pub(crate) use linux::{
apparmor_permits_userns, make_dumpable_for_map_writes, restore_dumpable, write_decimal_map,
};
#[cfg(not(target_os = "linux"))]
pub(crate) fn probe_sealed_support() -> SealedProbe {
SealedProbe::default()
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
mod linux {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use super::{
ComposedRootPlan, NamespaceProbe, RootOp, RootStep, SealedProbe, OLD_ROOT_NAME,
PROC_HIDEPID_OPTIONS, SEALED_APPARMOR_PROFILE_NAME, SEALED_ROOT_FAILURE_PREFIX,
};
pub(crate) fn probe_sealed_support() -> SealedProbe {
SealedProbe {
apparmor_permits_userns: apparmor_permits_userns(),
namespace: probe_namespace(),
}
}
pub(crate) fn apparmor_permits_userns() -> bool {
let enabled = read_trimmed("/sys/module/apparmor/parameters/enabled");
if !matches!(enabled.as_deref(), Some("Y") | Some("1")) {
return true;
}
let restricted =
read_trimmed("/sys/module/apparmor/parameters/restrict_unprivileged_userns")
.or_else(|| read_trimmed("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"));
if !matches!(restricted.as_deref(), Some("Y") | Some("1")) {
return true;
}
read_trimmed("/proc/self/attr/current")
.is_some_and(|current| current.starts_with(SEALED_APPARMOR_PROFILE_NAME))
}
fn read_trimmed(path: &str) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(crate) unsafe fn make_dumpable_for_map_writes() -> libc::c_int {
unsafe {
let previous = libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0);
libc::prctl(libc::PR_SET_DUMPABLE, 1 as libc::c_int, 0, 0, 0);
previous
}
}
pub(crate) unsafe fn restore_dumpable(previous: libc::c_int) {
if previous < 0 {
return;
}
unsafe {
libc::prctl(libc::PR_SET_DUMPABLE, previous, 0, 0, 0);
}
}
const PROBE_OK: i32 = 0;
const PROBE_UNSHARE_DENIED: i32 = 1;
const PROBE_UNSHARE_UNSUPPORTED: i32 = 2;
const PROBE_MOUNT_DENIED: i32 = 3;
const PROBE_MAP_DENIED: i32 = 4;
fn probe_namespace() -> NamespaceProbe {
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
let pid = unsafe { libc::fork() };
if pid < 0 {
return NamespaceProbe::Unsupported;
}
if pid == 0 {
unsafe {
if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
let errno = *libc::__errno_location();
libc::_exit(match errno {
libc::EPERM => PROBE_UNSHARE_DENIED,
_ => PROBE_UNSHARE_UNSUPPORTED,
});
}
let previous_dumpable = make_dumpable_for_map_writes();
let _ = write_decimal_map(c"/proc/self/setgroups", None, 0);
let mapped = write_decimal_map(c"/proc/self/uid_map", Some(uid), uid).is_ok()
&& write_decimal_map(c"/proc/self/gid_map", Some(gid), gid).is_ok();
restore_dumpable(previous_dumpable);
if !mapped {
libc::_exit(PROBE_MAP_DENIED);
}
let rc = libc::mount(
std::ptr::null(),
c"/".as_ptr(),
std::ptr::null(),
libc::MS_REC | libc::MS_PRIVATE,
std::ptr::null(),
);
libc::_exit(if rc == 0 {
PROBE_OK
} else {
PROBE_MOUNT_DENIED
});
}
}
let mut status: libc::c_int = 0;
let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
if waited != pid {
return NamespaceProbe::Unsupported;
}
if !libc::WIFEXITED(status) {
return NamespaceProbe::Unsupported;
}
match libc::WEXITSTATUS(status) {
PROBE_OK => NamespaceProbe::Ok,
PROBE_UNSHARE_DENIED => NamespaceProbe::Denied,
PROBE_MAP_DENIED => NamespaceProbe::MapDenied,
PROBE_MOUNT_DENIED => NamespaceProbe::MountDenied,
_ => NamespaceProbe::Unsupported,
}
}
pub(crate) unsafe fn write_decimal_map(
path: &std::ffi::CStr,
id: Option<libc::uid_t>,
value: libc::uid_t,
) -> Result<(), ()> {
let Some(_) = id else {
return write_file(path, b"deny");
};
let mut buf = [0u8; 32];
let mut len = 0usize;
let write_number = |buf: &mut [u8; 32], len: &mut usize, mut n: u32| {
let mut digits = [0u8; 10];
let mut count = 0;
loop {
digits[count] = b'0' + (n % 10) as u8;
count += 1;
n /= 10;
if n == 0 {
break;
}
}
while count > 0 {
count -= 1;
buf[*len] = digits[count];
*len += 1;
}
};
write_number(&mut buf, &mut len, value);
buf[len] = b' ';
len += 1;
write_number(&mut buf, &mut len, value);
buf[len] = b' ';
len += 1;
buf[len] = b'1';
len += 1;
buf[len] = b'\n';
len += 1;
write_file(path, &buf[..len])
}
struct CStep {
kind: CStepKind,
required: bool,
label: String,
}
enum CStepKind {
MkDir(CString),
MkFile(CString),
Symlink {
target: CString,
link: CString,
},
Bind {
source: CString,
target: CString,
read_only: bool,
},
Tmpfs {
target: CString,
options: CString,
},
Proc {
target: CString,
},
DevPts {
target: CString,
},
RemountReadOnly(CString),
}
pub(crate) struct SealedRootSpec {
uid_map: Vec<u8>,
gid_map: Vec<u8>,
base: CString,
old_root_full: CString,
old_root_relative: CString,
old_root_pivoted: CString,
steps: Vec<CStep>,
workdir_in_root: CString,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SealedRootFailure {
pub(crate) step: usize,
pub(crate) stage: &'static str,
pub(crate) errno: i32,
}
impl SealedRootFailure {
fn stage(stage: &'static str) -> Self {
Self {
step: usize::MAX,
stage,
errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
}
}
fn step(index: usize) -> Self {
Self {
step: index,
stage: "",
errno: std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
}
}
}
impl SealedRootSpec {
pub(crate) fn describe(&self, failure: SealedRootFailure) -> String {
let what = if failure.step == usize::MAX {
failure.stage.to_string()
} else {
self.steps
.get(failure.step)
.map(|step| step.label.clone())
.unwrap_or_else(|| format!("step {}", failure.step))
};
format!(
"{SEALED_ROOT_FAILURE_PREFIX} {what} failed: {}",
std::io::Error::from_raw_os_error(failure.errno)
)
}
}
pub(crate) fn build_sealed_root_spec(
plan: &ComposedRootPlan,
) -> Result<SealedRootSpec, String> {
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
let mut steps = Vec::with_capacity(plan.steps.len());
for RootStep { op, required } in &plan.steps {
let (kind, label) = match op {
RootOp::MkDir(path) => (
CStepKind::MkDir(cstr(path)?),
format!("mkdir {}", path.display()),
),
RootOp::MkFile(path) => (
CStepKind::MkFile(cstr(path)?),
format!("create {}", path.display()),
),
RootOp::Symlink { target, link } => (
CStepKind::Symlink {
target: cstr(target)?,
link: cstr(link)?,
},
format!("symlink {} -> {}", link.display(), target.display()),
),
RootOp::Bind {
source,
target,
read_only,
} => (
CStepKind::Bind {
source: cstr(source)?,
target: cstr(target)?,
read_only: *read_only,
},
format!(
"bind{} {} -> {}",
if *read_only { " (ro)" } else { "" },
source.display(),
target.display()
),
),
RootOp::Tmpfs { target, options } => (
CStepKind::Tmpfs {
target: cstr(target)?,
options: CString::new(*options).map_err(|error| error.to_string())?,
},
format!("tmpfs on {}", target.display()),
),
RootOp::Proc { target } => (
CStepKind::Proc {
target: cstr(target)?,
},
format!("proc on {}", target.display()),
),
RootOp::DevPts { target } => (
CStepKind::DevPts {
target: cstr(target)?,
},
format!("devpts on {}", target.display()),
),
RootOp::RemountReadOnly(path) => (
CStepKind::RemountReadOnly(cstr(path)?),
format!("remount read-only {}", path.display()),
),
};
steps.push(CStep {
kind,
required: *required,
label,
});
}
Ok(SealedRootSpec {
uid_map: format!("{uid} {uid} 1\n").into_bytes(),
gid_map: format!("{gid} {gid} 1\n").into_bytes(),
base: cstr(&plan.base)?,
old_root_full: cstr(&plan.base.join(OLD_ROOT_NAME))?,
old_root_relative: CString::new(OLD_ROOT_NAME).map_err(|e| e.to_string())?,
old_root_pivoted: CString::new(format!("/{OLD_ROOT_NAME}"))
.map_err(|e| e.to_string())?,
steps,
workdir_in_root: cstr(&plan.workdir_in_root)?,
})
}
fn cstr(path: &Path) -> Result<CString, String> {
CString::new(path.as_os_str().as_bytes())
.map_err(|_| format!("path contains an interior NUL: {}", path.display()))
}
pub(crate) fn construct_composed_root(spec: &SealedRootSpec) -> Result<(), SealedRootFailure> {
unsafe {
if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
return Err(SealedRootFailure::stage(
"unshare(CLONE_NEWUSER|CLONE_NEWNS)",
));
}
let previous_dumpable = make_dumpable_for_map_writes();
let _ = write_file(c"/proc/self/setgroups", b"deny");
let uid_mapped = write_file(c"/proc/self/uid_map", &spec.uid_map).is_ok();
let gid_mapped = uid_mapped && write_file(c"/proc/self/gid_map", &spec.gid_map).is_ok();
restore_dumpable(previous_dumpable);
if !uid_mapped {
return Err(SealedRootFailure::stage("write /proc/self/uid_map"));
}
if !gid_mapped {
return Err(SealedRootFailure::stage("write /proc/self/gid_map"));
}
if libc::mount(
std::ptr::null(),
c"/".as_ptr(),
std::ptr::null(),
libc::MS_REC | libc::MS_PRIVATE,
std::ptr::null(),
) != 0
{
return Err(SealedRootFailure::stage("mount --make-rprivate /"));
}
if libc::mount(
c"tmpfs".as_ptr(),
spec.base.as_ptr(),
c"tmpfs".as_ptr(),
libc::MS_NOSUID | libc::MS_NODEV,
c"mode=0755".as_ptr() as *const libc::c_void,
) != 0
{
return Err(SealedRootFailure::stage(
"mount tmpfs for the composed root",
));
}
for (index, step) in spec.steps.iter().enumerate() {
if execute_step(step).is_err() && step.required {
return Err(SealedRootFailure::step(index));
}
}
if libc::mkdir(spec.old_root_full.as_ptr(), 0o700) != 0 {
return Err(SealedRootFailure::stage(
"mkdir the old-root parking directory",
));
}
if libc::chdir(spec.base.as_ptr()) != 0 {
return Err(SealedRootFailure::stage("chdir into the composed root"));
}
if libc::syscall(
libc::SYS_pivot_root,
c".".as_ptr(),
spec.old_root_relative.as_ptr(),
) != 0
{
return Err(SealedRootFailure::stage("pivot_root"));
}
if libc::chdir(c"/".as_ptr()) != 0 {
return Err(SealedRootFailure::stage("chdir / after pivot_root"));
}
if libc::umount2(spec.old_root_pivoted.as_ptr(), libc::MNT_DETACH) != 0 {
return Err(SealedRootFailure::stage("umount2 the old root"));
}
libc::rmdir(spec.old_root_pivoted.as_ptr());
if libc::mount(
std::ptr::null(),
c"/".as_ptr(),
std::ptr::null(),
libc::MS_REMOUNT
| libc::MS_BIND
| libc::MS_RDONLY
| libc::MS_NOSUID
| libc::MS_NODEV,
std::ptr::null(),
) != 0
{
return Err(SealedRootFailure::stage(
"remount the composed root read-only",
));
}
if libc::chdir(spec.workdir_in_root.as_ptr()) != 0 {
return Err(SealedRootFailure::stage(
"chdir into the workdir inside the root",
));
}
}
Ok(())
}
unsafe fn execute_step(step: &CStep) -> Result<(), ()> {
match &step.kind {
CStepKind::MkDir(path) => {
if libc::mkdir(path.as_ptr(), 0o755) != 0
&& *libc::__errno_location() != libc::EEXIST
{
return Err(());
}
}
CStepKind::MkFile(path) => {
let fd = libc::open(
path.as_ptr(),
libc::O_CREAT | libc::O_WRONLY | libc::O_CLOEXEC,
libc::mode_t::from(0o644u16),
);
if fd < 0 {
return Err(());
}
libc::close(fd);
}
CStepKind::Symlink { target, link } => {
if libc::symlink(target.as_ptr(), link.as_ptr()) != 0 {
return Err(());
}
}
CStepKind::Bind {
source,
target,
read_only,
} => {
if libc::mount(
source.as_ptr(),
target.as_ptr(),
std::ptr::null(),
libc::MS_BIND | libc::MS_REC,
std::ptr::null(),
) != 0
{
return Err(());
}
if *read_only
&& libc::mount(
std::ptr::null(),
target.as_ptr(),
std::ptr::null(),
libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID,
std::ptr::null(),
) != 0
{
return Err(());
}
}
CStepKind::Tmpfs { target, options } => {
if libc::mount(
c"tmpfs".as_ptr(),
target.as_ptr(),
c"tmpfs".as_ptr(),
libc::MS_NOSUID,
options.as_ptr() as *const libc::c_void,
) != 0
{
return Err(());
}
}
CStepKind::Proc { target } => {
for option in PROC_HIDEPID_OPTIONS {
let mut buffer = [0u8; 32];
let bytes = option.as_bytes();
if bytes.len() >= buffer.len() {
continue;
}
buffer[..bytes.len()].copy_from_slice(bytes);
let data = if bytes.is_empty() {
std::ptr::null()
} else {
buffer.as_ptr() as *const libc::c_void
};
if libc::mount(
c"proc".as_ptr(),
target.as_ptr(),
c"proc".as_ptr(),
libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
data,
) == 0
{
return Ok(());
}
}
if libc::mount(
c"/proc".as_ptr(),
target.as_ptr(),
std::ptr::null(),
libc::MS_BIND | libc::MS_REC,
std::ptr::null(),
) != 0
{
return Err(());
}
libc::mount(
std::ptr::null(),
target.as_ptr(),
std::ptr::null(),
libc::MS_REMOUNT
| libc::MS_BIND
| libc::MS_NOSUID
| libc::MS_NODEV
| libc::MS_NOEXEC,
std::ptr::null(),
);
return Ok(());
}
CStepKind::DevPts { target } => {
if libc::mount(
c"devpts".as_ptr(),
target.as_ptr(),
c"devpts".as_ptr(),
libc::MS_NOSUID | libc::MS_NOEXEC,
c"newinstance,ptmxmode=0666,mode=0620".as_ptr() as *const libc::c_void,
) != 0
{
return Err(());
}
}
CStepKind::RemountReadOnly(target) => {
if libc::mount(
std::ptr::null(),
target.as_ptr(),
std::ptr::null(),
libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID,
std::ptr::null(),
) != 0
{
return Err(());
}
}
}
Ok(())
}
unsafe fn write_file(path: &std::ffi::CStr, data: &[u8]) -> Result<(), ()> {
let fd = libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
if fd < 0 {
return Err(());
}
let mut written = 0usize;
while written < data.len() {
let n = libc::write(
fd,
data[written..].as_ptr() as *const libc::c_void,
data.len() - written,
);
if n < 0 {
if *libc::__errno_location() == libc::EINTR {
continue;
}
libc::close(fd);
return Err(());
}
if n == 0 {
break;
}
written += n as usize;
}
libc::close(fd);
if written == data.len() {
Ok(())
} else {
Err(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
struct FakeHost(HashMap<PathBuf, PathKind>);
impl FakeHost {
fn usrmerge() -> Self {
let mut map = HashMap::new();
map.insert(PathBuf::from("/usr"), PathKind::Dir);
map.insert(
PathBuf::from("/bin"),
PathKind::Symlink(PathBuf::from("usr/bin")),
);
map.insert(
PathBuf::from("/sbin"),
PathKind::Symlink(PathBuf::from("usr/sbin")),
);
map.insert(
PathBuf::from("/lib"),
PathKind::Symlink(PathBuf::from("usr/lib")),
);
map.insert(
PathBuf::from("/lib64"),
PathKind::Symlink(PathBuf::from("usr/lib64")),
);
map.insert(PathBuf::from("/etc/ld.so.cache"), PathKind::File);
map.insert(PathBuf::from("/etc/ssl"), PathKind::Dir);
map.insert(PathBuf::from("/etc/passwd"), PathKind::File);
map.insert(PathBuf::from("/etc/group"), PathKind::File);
for device in SEALED_DEVICE_NODES {
map.insert(PathBuf::from(device.path), PathKind::File);
}
Self(map)
}
}
impl HostLayout for FakeHost {
fn kind(&self, path: &Path) -> Option<PathKind> {
self.0.get(path).cloned()
}
}
fn plan_for(workdir: &str) -> ComposedRootPlan {
plan_composed_root(
Path::new(workdir),
Path::new("/tmp"),
&[],
&[],
&FakeHost::usrmerge(),
)
}
fn ops(plan: &ComposedRootPlan) -> Vec<RootOp> {
plan.steps.iter().map(|step| step.op.clone()).collect()
}
#[test]
fn base_selection_skips_any_ancestor_of_the_workdir() {
assert_eq!(
choose_root_base(Path::new("/home/u/w"), SEALED_ROOT_BASE_CANDIDATES, |_| {
true
}),
Some(PathBuf::from("/tmp"))
);
assert_eq!(
choose_root_base(
Path::new("/tmp/session/w"),
SEALED_ROOT_BASE_CANDIDATES,
|_| true
),
Some(PathBuf::from("/run"))
);
assert_eq!(
choose_root_base(
Path::new("/home/u/w"),
SEALED_ROOT_BASE_CANDIDATES,
|path| { path != Path::new("/tmp") && path != Path::new("/run") }
),
Some(PathBuf::from("/var/tmp"))
);
assert_eq!(
choose_root_base(Path::new("/home/u/w"), SEALED_ROOT_BASE_CANDIDATES, |_| {
false
}),
None
);
}
#[test]
fn usrmerge_symlinks_are_reproduced_rather_than_bound_through() {
let plan = plan_for("/home/u/w");
assert!(ops(&plan).contains(&RootOp::Symlink {
target: PathBuf::from("usr/bin"),
link: PathBuf::from("/tmp/bin"),
}));
assert!(ops(&plan).contains(&RootOp::Bind {
source: PathBuf::from("/usr"),
target: PathBuf::from("/tmp/usr"),
read_only: true,
}));
assert!(!ops(&plan)
.iter()
.any(|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/lib32"))));
}
#[test]
fn every_runtime_and_etc_bind_is_read_only_and_only_the_workdir_is_not() {
let plan = plan_for("/home/u/w");
let writable: Vec<&PathBuf> = plan
.steps
.iter()
.filter_map(|step| match &step.op {
RootOp::Bind {
source,
read_only: false,
..
} => Some(source),
_ => None,
})
.collect();
for source in &writable {
assert!(
source.starts_with("/home/u/w") || source.starts_with("/dev/"),
"unexpected writable bind of {}",
source.display()
);
}
assert!(writable.contains(&&PathBuf::from("/home/u/w")));
}
#[test]
fn etc_is_an_allowlist_so_shadow_is_absent_by_construction() {
let plan = plan_for("/home/u/w");
let bound: Vec<String> = plan
.steps
.iter()
.filter_map(|step| match &step.op {
RootOp::Bind { source, .. } => Some(source.display().to_string()),
_ => None,
})
.collect();
assert!(bound.iter().any(|path| path == "/etc/ld.so.cache"));
assert!(!bound.iter().any(|path| path == "/etc"));
assert!(!bound.iter().any(|path| path.starts_with("/etc/shadow")));
assert!(!bound.iter().any(|path| path.starts_with("/root")));
assert!(!bound.iter().any(|path| path.starts_with("/home/u/.ssh")));
}
#[test]
fn passwd_and_group_are_bound_from_the_staging_files_never_from_the_host() {
let plan = plan_for("/home/u/w");
let workdir = Path::new("/home/u/w");
for host_path in ["/etc/passwd", "/etc/group"] {
assert!(
!plan.steps.iter().any(|step| matches!(
&step.op,
RootOp::Bind { source, .. } if source == Path::new(host_path)
)),
"the host's {host_path} carries every account on the machine and must not be \
bound into a capsule",
);
}
for (file, target) in [
(SyntheticEtcFile::Passwd, "/tmp/etc/passwd"),
(SyntheticEtcFile::Group, "/tmp/etc/group"),
] {
let expected = RootStep {
op: RootOp::Bind {
source: synthetic_etc_source(workdir, file),
target: PathBuf::from(target),
read_only: true,
},
required: true,
};
assert!(
plan.steps.contains(&expected),
"expected {expected:?} in {:?}",
plan.steps,
);
assert!(ops(&plan).contains(&RootOp::MkFile(PathBuf::from(target))));
}
assert!(ops(&plan).contains(&RootOp::Bind {
source: PathBuf::from("/etc/ld.so.cache"),
target: PathBuf::from("/tmp/etc/ld.so.cache"),
read_only: true,
}));
}
#[test]
fn the_synthetic_databases_name_root_and_the_capsules_own_id_and_nothing_else() {
let identity = SealedAccountIdentity {
uid: 1000,
gid: 1000,
home: "/w/.capsule-home",
};
let passwd = SyntheticEtcFile::Passwd.render(&identity).unwrap();
assert_eq!(
passwd,
"root:x:0:0:root:/root:/bin/sh\n\
capsule:x:1000:1000:Murmur capsule:/w/.capsule-home:/bin/sh\n"
);
let group = SyntheticEtcFile::Group.render(&identity).unwrap();
assert_eq!(group, "root:x:0:\ncapsule:x:1000:\n");
for (file, contents) in [
(SyntheticEtcFile::Passwd, &passwd),
(SyntheticEtcFile::Group, &group),
] {
assert_eq!(
contents.lines().count(),
2,
"{file:?} must stay a two-line database"
);
assert!(
contents.ends_with('\n'),
"{file:?} must end its last record with a newline"
);
}
}
#[test]
fn a_root_capsule_gets_a_single_passwd_line_carrying_its_own_home() {
let identity = SealedAccountIdentity {
uid: 0,
gid: 0,
home: "/w/.capsule-home",
};
assert_eq!(
SyntheticEtcFile::Passwd.render(&identity).unwrap(),
"root:x:0:0:root:/w/.capsule-home:/bin/sh\n"
);
assert_eq!(
SyntheticEtcFile::Group.render(&identity).unwrap(),
"root:x:0:\n"
);
}
#[test]
fn a_home_path_that_cannot_be_spelled_in_a_passwd_field_is_refused() {
for home in ["/w/od:d", "/w/two\nlines"] {
let identity = SealedAccountIdentity {
uid: 1000,
gid: 1000,
home,
};
let error = SyntheticEtcFile::Passwd.render(&identity).unwrap_err();
assert!(
error.contains(home),
"the message must name the offending path: {error}"
);
}
}
#[test]
fn the_workdir_keeps_its_absolute_path_inside_the_root() {
let plan = plan_for("/home/u/.murmur/sessions/abc");
assert_eq!(
plan.workdir_in_root,
PathBuf::from("/home/u/.murmur/sessions/abc")
);
assert!(ops(&plan).contains(&RootOp::Bind {
source: PathBuf::from("/home/u/.murmur/sessions/abc"),
target: PathBuf::from("/tmp/home/u/.murmur/sessions/abc"),
read_only: false,
}));
let mkdirs: Vec<&RootOp> = plan
.steps
.iter()
.map(|step| &step.op)
.filter(|op| matches!(op, RootOp::MkDir(path) if path.starts_with("/tmp/home")))
.collect();
assert_eq!(mkdirs.len(), 5, "got {mkdirs:?}");
}
#[test]
fn tmp_is_backed_by_a_directory_inside_the_workdir() {
let plan = plan_for("/home/u/w");
assert!(ops(&plan).contains(&RootOp::Bind {
source: PathBuf::from("/home/u/w").join(SEALED_TMP_DIR_NAME),
target: PathBuf::from("/tmp/tmp"),
read_only: false,
}));
}
#[test]
fn a_workdir_under_tmp_survives_the_composed_tmp_mounted_over_its_path() {
let workdir = "/tmp/session-1234/w";
let plan = plan_for(workdir);
let operations = ops(&plan);
assert_eq!(
choose_root_base(Path::new(workdir), SEALED_ROOT_BASE_CANDIDATES, |_| true),
Some(PathBuf::from("/run")),
);
let tmp_index = operations
.iter()
.position(
|op| matches!(op, RootOp::Bind { target, .. } if target == Path::new("/tmp/tmp")),
)
.expect("the composed /tmp is bound");
let workdir_index = operations
.iter()
.position(
|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new(workdir)),
)
.expect("the workdir is bound");
assert!(
tmp_index < workdir_index,
"/tmp must be mounted before the workdir path is built inside it, or the workdir bind \
is hidden by it",
);
}
#[test]
fn dev_carries_the_oci_default_set_and_is_sealed_afterwards() {
let plan = plan_for("/home/u/w");
let operations = ops(&plan);
for device in SEALED_DEVICE_NODES {
assert!(
operations.contains(&RootOp::Bind {
source: PathBuf::from("/dev").join(device.name),
target: PathBuf::from("/tmp/dev").join(device.name),
read_only: false,
}),
"missing /dev/{}",
device.name
);
}
assert!(!operations.iter().any(
|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/dev/sda"))
));
let tmpfs_index = operations
.iter()
.position(
|op| matches!(op, RootOp::Tmpfs { target, .. } if target == Path::new("/tmp/dev")),
)
.expect("/dev tmpfs");
let sealed_index = operations
.iter()
.position(
|op| matches!(op, RootOp::RemountReadOnly(path) if path == Path::new("/tmp/dev")),
)
.expect("/dev sealed read-only");
assert!(
tmpfs_index < sealed_index,
"the device nodes must be bound before /dev is sealed"
);
}
#[test]
fn proc_is_planned_and_masking_is_preferred_over_an_unmasked_mount() {
let plan = plan_for("/home/u/w");
assert!(ops(&plan).contains(&RootOp::Proc {
target: PathBuf::from("/tmp/proc")
}));
assert_eq!(PROC_HIDEPID_OPTIONS[0], "hidepid=2");
assert_eq!(
PROC_HIDEPID_OPTIONS.last(),
Some(&""),
"the unmasked spelling must be tried last, after every masked one",
);
}
#[test]
fn manifest_derived_directories_are_whole_directory_binds_and_deduped() {
let mut host = FakeHost::usrmerge();
host.0
.insert(PathBuf::from("/opt/python3.12"), PathKind::Dir);
let plan = plan_composed_root(
Path::new("/home/u/w"),
Path::new("/tmp"),
&[PathBuf::from("/opt/python3.12"), PathBuf::from("/usr")],
&[],
&host,
);
let operations = ops(&plan);
assert!(operations.contains(&RootOp::Bind {
source: PathBuf::from("/opt/python3.12"),
target: PathBuf::from("/tmp/opt/python3.12"),
read_only: true,
}));
assert_eq!(
operations
.iter()
.filter(
|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/usr"))
)
.count(),
1
);
}
#[test]
fn a_staged_runtime_path_absent_from_the_host_still_plans_a_required_bind() {
let plan = plan_composed_root(
Path::new("/home/u/w"),
Path::new("/tmp"),
&[PathBuf::from("/opt/optional-tree")],
&[PathBuf::from("/opt/staged-tree")],
&FakeHost::usrmerge(),
);
assert!(
!plan.steps.iter().any(|step| matches!(
&step.op,
RootOp::Bind { source, .. } if source == Path::new("/opt/optional-tree")
)),
"an absent extra_read_only path must plan no step",
);
let staged = plan
.steps
.iter()
.find(|step| {
matches!(
&step.op,
RootOp::Bind { source, .. } if source == Path::new("/opt/staged-tree")
)
})
.expect("an absent staged_runtime path must still plan a bind");
assert_eq!(
staged.op,
RootOp::Bind {
source: PathBuf::from("/opt/staged-tree"),
target: PathBuf::from("/tmp/opt/staged-tree"),
read_only: true,
},
"staged trees are re-based read-only binds at their own absolute path",
);
assert!(
staged.required,
"a staged_runtime bind must be required, so a missing source is session-fatal \
rather than a silently narrower root",
);
}
#[test]
fn staged_runtime_binds_are_planned_before_every_other_step() {
let mut host = FakeHost::usrmerge();
host.0
.insert(PathBuf::from("/opt/staged-tree"), PathKind::Dir);
let plan = plan_composed_root(
Path::new("/home/u/w"),
Path::new("/tmp"),
&[],
&[PathBuf::from("/opt/staged-tree")],
&host,
);
let position = |predicate: &dyn Fn(&RootOp) -> bool| {
plan.steps
.iter()
.position(|step| predicate(&step.op))
.expect("step must be planned")
};
let staged = position(
&|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/opt/staged-tree")),
);
let fixed_runtime_tree = position(
&|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/usr")),
);
let etc = position(
&|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/etc/ssl")),
);
let dev = position(
&|op| matches!(op, RootOp::Tmpfs { target, .. } if target == Path::new("/tmp/dev")),
);
let proc = position(&|op| matches!(op, RootOp::Proc { .. }));
let tmp = position(
&|op| matches!(op, RootOp::Bind { target, .. } if target == Path::new("/tmp/tmp")),
);
let workdir = position(
&|op| matches!(op, RootOp::Bind { source, .. } if source == Path::new("/home/u/w")),
);
assert!(
staged < fixed_runtime_tree,
"staged binds precede the fixed runtime tree"
);
assert!(staged < etc, "staged binds precede /etc");
assert!(staged < dev, "staged binds precede /dev");
assert!(staged < proc, "staged binds precede /proc");
assert!(staged < tmp, "staged binds precede /tmp");
assert!(staged < workdir, "staged binds precede the workdir bind");
}
#[test]
fn a_path_named_both_staged_and_optional_stays_required() {
let mut host = FakeHost::usrmerge();
host.0.insert(PathBuf::from("/opt/shared"), PathKind::Dir);
let plan = plan_composed_root(
Path::new("/home/u/w"),
Path::new("/tmp"),
&[PathBuf::from("/opt/shared")],
&[PathBuf::from("/opt/shared")],
&host,
);
let binds: Vec<&RootStep> = plan
.steps
.iter()
.filter(|step| {
matches!(
&step.op,
RootOp::Bind { source, .. } if source == Path::new("/opt/shared")
)
})
.collect();
assert_eq!(
binds.len(),
1,
"the shared target is registered exactly once"
);
assert!(
binds[0].required,
"and the required registration is the one that won"
);
}
#[test]
fn blocker_blames_apparmor_before_the_namespace_outcome_it_causes() {
let probe = SealedProbe {
apparmor_permits_userns: false,
namespace: NamespaceProbe::Denied,
};
assert_eq!(
sealed_blocker(true, true, probe),
Some(SealedBlocker::AppArmorProfileMissing)
);
let reason = SealedBlocker::AppArmorProfileMissing.reason();
assert!(reason.contains("mur-sealed"));
assert!(reason.contains("apparmor_parser -r"));
}
#[test]
fn blocker_names_the_container_remediation_verbatim() {
let probe = SealedProbe {
apparmor_permits_userns: true,
namespace: NamespaceProbe::Denied,
};
assert_eq!(
sealed_blocker(true, true, probe),
Some(SealedBlocker::NamespaceCreationDenied)
);
let reason = SealedBlocker::NamespaceCreationDenied.reason();
assert!(reason.contains("--cap-add SYS_ADMIN"));
assert!(reason.contains("outside the container"));
}
#[test]
fn a_map_failure_is_not_reported_as_the_container_case() {
let probe = SealedProbe {
apparmor_permits_userns: true,
namespace: NamespaceProbe::MapDenied,
};
assert_eq!(
sealed_blocker(true, true, probe),
Some(SealedBlocker::IdMapDenied)
);
let reason = SealedBlocker::IdMapDenied.reason();
assert!(
reason.contains("uid_map"),
"must name the step that failed: {reason}"
);
assert!(
!reason.contains("CAP_SYS_ADMIN") && !reason.contains("--cap-add"),
"must not send the operator after a capability that isn't the problem: {reason}",
);
let denied = SealedProbe {
apparmor_permits_userns: true,
namespace: NamespaceProbe::Denied,
};
assert_eq!(
sealed_blocker(true, true, denied),
Some(SealedBlocker::NamespaceCreationDenied)
);
assert_ne!(
SealedBlocker::IdMapDenied.reason(),
SealedBlocker::NamespaceCreationDenied.reason(),
);
}
#[test]
fn every_blocker_is_listed_in_all_with_its_own_actionable_reason() {
for blocker in SealedBlocker::ALL {
let named = match blocker {
SealedBlocker::NotLinux
| SealedBlocker::AppArmorProfileMissing
| SealedBlocker::NamespaceCreationDenied
| SealedBlocker::IdMapDenied
| SealedBlocker::MountDenied
| SealedBlocker::KernelUnsupported
| SealedBlocker::LandlockUnavailable => *blocker,
};
assert!(
SealedBlocker::ALL.contains(&named),
"{named:?} is missing from SealedBlocker::ALL",
);
}
let reasons: Vec<String> = SealedBlocker::ALL
.iter()
.map(|blocker| blocker.reason())
.collect();
for reason in &reasons {
assert!(reason.starts_with("sealed "), "got: {reason}");
assert!(!reason.contains('\n'), "got: {reason}");
assert!(
reason.len() > 80,
"a blocker reason must name the mechanism and its remediation, not just fail: \
{reason}",
);
}
let mut unique = reasons.clone();
unique.sort();
unique.dedup();
assert_eq!(
unique.len(),
reasons.len(),
"two blockers share a reason, so the refusal cannot say which one applies",
);
}
#[test]
fn blocker_is_none_only_when_every_precondition_holds() {
let ok = SealedProbe {
apparmor_permits_userns: true,
namespace: NamespaceProbe::Ok,
};
assert_eq!(sealed_blocker(true, true, ok), None);
assert_eq!(
sealed_blocker(false, true, ok),
Some(SealedBlocker::NotLinux)
);
assert_eq!(
sealed_blocker(true, false, ok),
Some(SealedBlocker::LandlockUnavailable)
);
assert_eq!(
sealed_blocker(
true,
true,
SealedProbe {
apparmor_permits_userns: true,
namespace: NamespaceProbe::MountDenied
}
),
Some(SealedBlocker::MountDenied)
);
assert_eq!(
sealed_blocker(
true,
true,
SealedProbe {
apparmor_permits_userns: true,
namespace: NamespaceProbe::Unsupported
}
),
Some(SealedBlocker::KernelUnsupported)
);
}
#[test]
fn the_device_set_is_exactly_the_oci_default_and_excludes_shm() {
let names: Vec<&str> = SEALED_DEVICE_NODES.iter().map(|d| d.name).collect();
assert_eq!(names, ["null", "zero", "full", "random", "urandom", "tty"]);
assert!(!names.contains(&"shm"));
assert!(!names.contains(&"console"));
assert_eq!(
SEALED_DEVICE_NODES
.iter()
.map(|d| (d.path, d.major, d.minor, d.mode, d.writable))
.collect::<Vec<_>>(),
[
("/dev/null", 1, 3, 0o666, true),
("/dev/zero", 1, 5, 0o666, true),
("/dev/full", 1, 7, 0o666, true),
("/dev/random", 1, 8, 0o666, false),
("/dev/urandom", 1, 9, 0o666, false),
("/dev/tty", 5, 0, 0o666, true),
]
);
}
}