#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
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";
pub const SEALED_APPARMOR_PROFILE_SHA256: &str =
"1669f6c0038dddea393cfacd95b078ac99ec718a538c8ebb52701f6ba686a892";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UsernsGrant {
ApparmorAbsent,
RestrictionDisabledHostWide,
ProfileConfining,
#[default]
Withheld,
}
impl UsernsGrant {
pub const ALL: &'static [UsernsGrant] = &[
UsernsGrant::ApparmorAbsent,
UsernsGrant::RestrictionDisabledHostWide,
UsernsGrant::ProfileConfining,
UsernsGrant::Withheld,
];
#[must_use]
pub fn wire_name(self) -> &'static str {
match self {
UsernsGrant::ApparmorAbsent => "apparmor_absent",
UsernsGrant::RestrictionDisabledHostWide => "restriction_disabled_host_wide",
UsernsGrant::ProfileConfining => "profile_confining",
UsernsGrant::Withheld => "withheld",
}
}
#[must_use]
pub fn summary(self) -> &'static str {
match self {
UsernsGrant::ApparmorAbsent => {
"AppArmor is not enabled on this host, so nothing restricts unprivileged user \
namespaces and no profile is needed"
}
UsernsGrant::RestrictionDisabledHostWide => {
"kernel.apparmor_restrict_unprivileged_userns is off, so unprivileged user \
namespaces are unrestricted for every binary on this host, not just for mur"
}
UsernsGrant::ProfileConfining => {
"the restriction is on and the mur-sealed AppArmor profile is confining this \
binary, so the grant covers mur alone — the configuration murmur ships"
}
UsernsGrant::Withheld => {
"the restriction is on and no mur-sealed AppArmor profile is confining this \
binary, so nothing murmur ships grants it a user namespace"
}
}
}
#[must_use]
pub fn from_readings(
enabled: Option<&str>,
restricted: Option<&str>,
current_profile: Option<&str>,
) -> UsernsGrant {
if !matches!(enabled, Some("Y") | Some("1")) {
return UsernsGrant::ApparmorAbsent;
}
if !matches!(restricted, Some("Y") | Some("1")) {
return UsernsGrant::RestrictionDisabledHostWide;
}
match current_profile {
Some(profile) if profile.starts_with(SEALED_APPARMOR_PROFILE_NAME) => {
UsernsGrant::ProfileConfining
}
_ => UsernsGrant::Withheld,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstalledProfileState {
Matches,
Drifted {
installed_sha256: String,
},
Absent,
Unreadable {
error: String,
},
}
#[must_use]
pub fn classify_installed_profile(read: Result<Vec<u8>, std::io::Error>) -> InstalledProfileState {
match read {
Ok(bytes) => {
let installed_sha256 = murmur_artifact::sha256_hex(&bytes);
if installed_sha256 == SEALED_APPARMOR_PROFILE_SHA256 {
InstalledProfileState::Matches
} else {
InstalledProfileState::Drifted { installed_sha256 }
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => InstalledProfileState::Absent,
Err(error) => InstalledProfileState::Unreadable {
error: error.to_string(),
},
}
}
#[must_use]
pub fn inspect_installed_profile() -> InstalledProfileState {
classify_installed_profile(std::fs::read(SEALED_APPARMOR_PROFILE_PATH))
}
pub const SEALED_APPARMOR_ATTACHMENT_PATHS: &[&str] = &[
"/{usr/local/,usr/,opt/mur/}bin/mur",
"@{HOME}/{.local,.cargo}/bin/mur",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileAttachment {
Sealed {
profile: String,
},
Other {
profile: String,
},
Unconfined,
NotReported,
}
#[must_use]
pub fn classify_profile_attachment(current_profile: Option<&str>) -> ProfileAttachment {
let Some(raw) = current_profile
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ProfileAttachment::NotReported;
};
let name = raw
.split_once(" (")
.map_or(raw, |(name, _mode)| name)
.trim();
if name.is_empty() || name == "unconfined" {
return ProfileAttachment::Unconfined;
}
if name.starts_with(SEALED_APPARMOR_PROFILE_NAME) {
return ProfileAttachment::Sealed {
profile: name.to_string(),
};
}
ProfileAttachment::Other {
profile: name.to_string(),
}
}
#[must_use]
pub fn inspect_profile_attachment() -> ProfileAttachment {
classify_profile_attachment(
std::fs::read_to_string("/proc/self/attr/current")
.ok()
.as_deref(),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SealedBlocker {
NotLinux,
AppArmorProfileMissing,
NamespaceCreationDenied,
IdMapDenied,
MountDenied,
PivotRootDenied,
KernelUnsupported,
LandlockUnavailable,
NetworkNamespaceUnavailable,
ProbeUnavailable,
}
impl SealedBlocker {
pub const ALL: &'static [SealedBlocker] = &[
SealedBlocker::NotLinux,
SealedBlocker::AppArmorProfileMissing,
SealedBlocker::NamespaceCreationDenied,
SealedBlocker::IdMapDenied,
SealedBlocker::MountDenied,
SealedBlocker::PivotRootDenied,
SealedBlocker::KernelUnsupported,
SealedBlocker::LandlockUnavailable,
SealedBlocker::NetworkNamespaceUnavailable,
SealedBlocker::ProbeUnavailable,
];
#[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. Building out of a \
checkout, where the binary sits at ./target/{{debug,release}}/mur and no shipped \
profile attaches to it: run `scripts/install-dev-apparmor.sh`, which generates \
and loads the same grant for those two paths. LAST RESORT, only where a profile \
genuinely cannot be loaded: `sudo sysctl -w \
kernel.apparmor_restrict_unprivileged_userns=0` — this removes \
unprivileged-userns hardening from every program on the machine, not just from \
mur, and is not the configuration murmur ships.",
name = SEALED_APPARMOR_PROFILE_NAME,
path = SEALED_APPARMOR_PROFILE_PATH,
),
SealedBlocker::NamespaceCreationDenied => {
"sealed requires an unprivileged user namespace, which this host refused to create. \
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 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::PivotRootDenied => format!(
"sealed created a user+mount namespace and mounted inside it, but pivot_root(2) \
was refused, so the composed root cannot become this capsule's root. mount(2) \
working while pivot_root(2) does not is what a policy that grants one and not \
the other looks like: on an AppArmor host, check that the loaded profile carries \
a `pivot_root,` rule and reload it with `sudo apparmor_parser -r {path}`; inside \
a container, pivot_root(2) is commonly absent from the runtime's seccomp \
allowlist even where CAP_SYS_ADMIN is granted, so establish the mount namespace \
outside the container instead. The runtime will not fall back to a weaker class.",
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()
}
SealedBlocker::NetworkNamespaceUnavailable => {
"sealed is built inside the capsule's network namespace and this host could not \
create or enter it, so there is no namespace to build the composed root in. \
Every capsule that can spawn a subprocess needs that namespace: see E-CAP-005 \
for the step that failed and its remedy, then re-run. The runtime will not fall \
back to a weaker class."
.to_string()
}
SealedBlocker::ProbeUnavailable => {
"sealed could not be probed: the probe could not run, so whether this host can \
back sealed is unknown; refused rather than assumed. A process or descriptor \
limit, or memory, ran out, or a policy killed the probe's child: re-run once \
the limit is relieved, or allow the probe's syscalls."
.to_string()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SealedShortfall {
pub blocker: SealedBlocker,
pub failed_call: Option<String>,
pub errno: Option<i32>,
pub userns_grant: UsernsGrant,
pub attachment: ProfileAttachment,
}
impl SealedShortfall {
#[must_use]
pub fn reason(&self) -> String {
let mut reason = self.blocker.reason();
let Some(call) = &self.failed_call else {
return reason;
};
let what = match self.errno {
Some(errno) => crate::namespace_probe::probe_failure_sentence(call, errno),
None => format!("The probe could not run: {call}."),
};
reason.push(' ');
reason.push_str(&what);
reason.push(' ');
reason.push_str(&crate::network_namespace::host_readings(
self.userns_grant,
&self.attachment,
));
reason
}
}
pub(crate) fn sealed_shortfall(
is_linux: bool,
landlock_fully_enforced: bool,
reading: &crate::namespace_probe::NamespaceReading,
) -> Option<SealedShortfall> {
use crate::namespace_probe::{MountAttempt, MountAttemptError, MountStep};
use crate::network_namespace::{CreationStep, NamespaceAttemptError};
let shortfall = |blocker, failed_call: Option<String>, errno| SealedShortfall {
blocker,
failed_call,
errno,
userns_grant: reading.userns_grant,
attachment: reading.attachment.clone(),
};
if !is_linux {
return Some(shortfall(SealedBlocker::NotLinux, None, None));
}
const KERNEL_ERRNOS: [i32; 4] = [libc::ENOSPC, libc::EINVAL, libc::ENOSYS, libc::EUSERS];
match &reading.network {
Ok(()) => {}
Err(NamespaceAttemptError::Creator(detail)) => {
return Some(shortfall(
SealedBlocker::ProbeUnavailable,
Some(detail.clone()),
None,
))
}
Err(NamespaceAttemptError::Step { step, errno }) => {
let errno = *errno;
let blocker = match step {
CreationStep::Unshare if KERNEL_ERRNOS.contains(&errno) => {
SealedBlocker::KernelUnsupported
}
CreationStep::Unshare | CreationStep::IdentityMaps
if matches!(errno, libc::EPERM | libc::EACCES)
&& reading.userns_grant == UsernsGrant::Withheld
&& reading.attachment == ProfileAttachment::Unconfined =>
{
SealedBlocker::AppArmorProfileMissing
}
CreationStep::Unshare => SealedBlocker::NamespaceCreationDenied,
CreationStep::IdentityMaps => SealedBlocker::IdMapDenied,
CreationStep::Loopback
| CreationStep::LocalRoute
| CreationStep::TcpListener
| CreationStep::Resolver
| CreationStep::NamespaceHandles
| CreationStep::HandOff => SealedBlocker::NetworkNamespaceUnavailable,
};
return Some(shortfall(
blocker,
Some(step.describe().to_string()),
Some(errno),
));
}
}
match &reading.sealed_root {
MountAttempt::Created => {}
MountAttempt::NotAttempted => {
return Some(shortfall(
SealedBlocker::ProbeUnavailable,
Some("the sealed-root rehearsal did not run".to_string()),
None,
))
}
MountAttempt::Failed(MountAttemptError::Rehearsal(detail)) => {
return Some(shortfall(
SealedBlocker::ProbeUnavailable,
Some(detail.clone()),
None,
))
}
MountAttempt::Failed(MountAttemptError::Step { step, errno }) => {
let errno = *errno;
let blocker = match step {
MountStep::JoinSession => SealedBlocker::NetworkNamespaceUnavailable,
MountStep::UnshareUserMount if KERNEL_ERRNOS.contains(&errno) => {
SealedBlocker::KernelUnsupported
}
MountStep::UnshareUserMount => SealedBlocker::NamespaceCreationDenied,
MountStep::IdentityMaps => SealedBlocker::IdMapDenied,
MountStep::MakePrivateNested | MountStep::Tmpfs => SealedBlocker::MountDenied,
MountStep::PivotPrepare | MountStep::PivotRoot | MountStep::DetachOldRoot => {
SealedBlocker::PivotRootDenied
}
MountStep::UnshareMount | MountStep::MakePrivate | MountStep::BindTmp => {
SealedBlocker::MountDenied
}
};
return Some(shortfall(
blocker,
Some(step.describe().to_string()),
Some(errno),
));
}
}
if landlock_fully_enforced {
None
} else {
Some(shortfall(SealedBlocker::LandlockUnavailable, None, None))
}
}
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(crate) fn synthetic_etc_source(sealed_etc_dir: &Path, file: SyntheticEtcFile) -> PathBuf {
sealed_etc_dir.join(file.file_name())
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct SealedBindSources<'a> {
pub(crate) tmp_store: (&'a Path, (u64, u64)),
pub(crate) etc_dir: &'a Path,
pub(crate) runtime_dir: Option<(&'a Path, (u64, u64))>,
pub(crate) records_dir: Option<(&'a Path, (u64, u64))>,
pub(crate) home_dir: Option<(&'a Path, (u64, u64))>,
}
const OLD_ROOT_NAME: &str = ".mur-oldroot";
pub(crate) const OLD_ROOT_NAME_C: &std::ffi::CStr = c".mur-oldroot";
pub(crate) const OLD_ROOT_PIVOTED_C: &std::ffi::CStr = c"/.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,
},
BindVerified {
source: PathBuf,
target: PathBuf,
read_only: bool,
identity: (u64, u64),
},
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(
accessible_workdir: &Path,
sources: &SealedBindSources<'_>,
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(sources.etc_dir, 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::BindVerified {
source: sources.tmp_store.0.to_path_buf(),
target: tmp,
read_only: false,
identity: sources.tmp_store.1,
},
true,
);
let workdir_target = rebase(base, accessible_workdir);
builder.mkdir_p(&workdir_target);
builder.push(
RootOp::Bind {
source: accessible_workdir.to_path_buf(),
target: workdir_target,
read_only: false,
},
true,
);
if let Some((path, identity)) = sources.runtime_dir {
let target = rebase(base, path);
builder.mkdir_p(&target);
builder.push(
RootOp::BindVerified {
source: path.to_path_buf(),
target,
read_only: true,
identity,
},
true,
);
}
if let Some((path, identity)) = sources.records_dir {
let target = rebase(base, path);
builder.mkdir_p(&target);
builder.push(
RootOp::BindVerified {
source: path.to_path_buf(),
target,
read_only: true,
identity,
},
true,
);
}
if let Some((path, identity)) = sources.home_dir {
let target = rebase(base, path);
builder.mkdir_p(&target);
builder.push(
RootOp::BindVerified {
source: path.to_path_buf(),
target,
read_only: false,
identity,
},
true,
);
}
ComposedRootPlan {
base: base.to_path_buf(),
steps: builder.steps,
workdir_in_root: accessible_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, SealedRootSpec};
#[cfg(target_os = "linux")]
pub(crate) use linux::{
make_dumpable_for_map_writes, open_verified_dir, proc_fd_path, remount_read_only,
restore_dumpable, userns_grant, write_decimal_map, PROC_FD_PATH_LEN,
};
#[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, RootOp, RootStep, UsernsGrant, OLD_ROOT_NAME, PROC_HIDEPID_OPTIONS,
SEALED_ROOT_FAILURE_PREFIX,
};
pub(crate) fn userns_grant() -> UsernsGrant {
let enabled = read_trimmed("/sys/module/apparmor/parameters/enabled");
let restricted =
read_trimmed("/sys/module/apparmor/parameters/restrict_unprivileged_userns")
.or_else(|| read_trimmed("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"));
let current = read_trimmed("/proc/self/attr/current");
UsernsGrant::from_readings(
enabled.as_deref(),
restricted.as_deref(),
current.as_deref(),
)
}
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);
}
}
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;
push_decimal(&mut buf, &mut len, value);
buf[len] = b' ';
len += 1;
push_decimal(&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,
},
BindVerified {
source: CString,
target: CString,
read_only: bool,
identity: (u64, u64),
},
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) unsafe fn remount_read_only(target: &std::ffi::CStr) -> Result<(), ()> {
let mut stat: libc::statvfs = std::mem::zeroed();
let kept = if libc::statvfs(target.as_ptr(), &mut stat) == 0 {
remount_flags_kept(stat.f_flag)
} else {
0
};
if libc::mount(
std::ptr::null(),
target.as_ptr(),
std::ptr::null(),
libc::MS_REMOUNT | libc::MS_BIND | libc::MS_RDONLY | libc::MS_NOSUID | kept,
std::ptr::null(),
) != 0
{
return Err(());
}
Ok(())
}
pub(crate) const PROC_FD_PATH_LEN: usize = 32;
pub(crate) fn proc_fd_path(
fd: libc::c_int,
out: &mut [u8; PROC_FD_PATH_LEN],
) -> Result<(), ()> {
const PREFIX: &[u8] = b"/proc/self/fd/";
if fd < 0 {
return Err(());
}
out[..PREFIX.len()].copy_from_slice(PREFIX);
let mut len = PREFIX.len();
push_decimal(out, &mut len, fd as u32);
out[len] = 0;
Ok(())
}
fn push_decimal(buf: &mut [u8], 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;
}
}
pub(crate) unsafe fn open_verified_dir(
source: &CString,
identity: (u64, u64),
) -> Result<libc::c_int, ()> {
let fd = libc::open(
source.as_ptr(),
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
);
if fd < 0 {
return Err(());
}
let mut stat: libc::stat = std::mem::zeroed();
if libc::fstat(fd, &mut stat) != 0 {
let errno = *libc::__errno_location();
libc::close(fd);
*libc::__errno_location() = errno;
return Err(());
}
if (stat.st_dev as u64, stat.st_ino as u64) != identity {
libc::close(fd);
*libc::__errno_location() = libc::ESTALE;
return Err(());
}
Ok(fd)
}
pub(crate) fn remount_flags_kept(f_flags: libc::c_ulong) -> libc::c_ulong {
let has = |flag: libc::c_ulong| f_flags & flag != 0;
let mut kept: libc::c_ulong = 0;
for (st, ms) in [
(libc::ST_NODEV, libc::MS_NODEV),
(libc::ST_NOEXEC, libc::MS_NOEXEC),
(libc::ST_NOSUID, libc::MS_NOSUID),
(libc::ST_NOATIME, libc::MS_NOATIME),
(libc::ST_NODIRATIME, libc::MS_NODIRATIME),
(libc::ST_RELATIME, libc::MS_RELATIME),
] {
if has(st) {
kept |= ms;
}
}
if !has(libc::ST_NOATIME) && !has(libc::ST_RELATIME) {
kept |= libc::MS_STRICTATIME;
}
kept
}
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::BindVerified {
source,
target,
read_only,
identity,
} => (
CStepKind::BindVerified {
source: cstr(source)?,
target: cstr(target)?,
read_only: *read_only,
identity: *identity,
},
format!(
"bind{} {} (dev:ino {}:{}) -> {}",
if *read_only { " (ro)" } else { "" },
source.display(),
identity.0,
identity.1,
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 {
remount_read_only(target)?;
}
}
CStepKind::BindVerified {
source,
target,
read_only,
identity,
} => {
let fd = open_verified_dir(source, *identity)?;
let mut path = [0u8; PROC_FD_PATH_LEN];
let bound = proc_fd_path(fd, &mut path).is_ok()
&& libc::mount(
path.as_ptr() as *const libc::c_char,
target.as_ptr(),
std::ptr::null(),
libc::MS_BIND | libc::MS_REC,
std::ptr::null(),
) == 0;
let errno = *libc::__errno_location();
libc::close(fd);
if !bound {
*libc::__errno_location() = errno;
return Err(());
}
if *read_only {
remount_read_only(target)?;
}
}
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()
}
}
const TEST_TMP_SOURCE: &str = "/home/u/.murmur/records/home/u/w/ses_plan_tests/tmp";
const TEST_TMP_IDENTITY: (u64, u64) = (64, 7);
const TEST_ETC_DIR: &str = "/home/u/.murmur/sessions/ses_plan_tests/sealed-etc";
const TEST_RUNTIME_DIR: &str = "/home/u/.murmur/sessions/ses_plan_tests/runtime";
const TEST_RUNTIME_IDENTITY: (u64, u64) = (64, 8);
fn test_sources() -> SealedBindSources<'static> {
SealedBindSources {
tmp_store: (Path::new(TEST_TMP_SOURCE), TEST_TMP_IDENTITY),
etc_dir: Path::new(TEST_ETC_DIR),
runtime_dir: None,
records_dir: None,
home_dir: None,
}
}
fn plan_for(workdir: &str) -> ComposedRootPlan {
plan_composed_root(
Path::new(workdir),
&test_sources(),
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");
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(Path::new(TEST_ETC_DIR), 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: "/r/ses/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:/r/ses/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: "/r/ses/home",
};
assert_eq!(
SyntheticEtcFile::Passwd.render(&identity).unwrap(),
"root:x:0:0:root:/r/ses/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_runtime_directory_is_bound_read_only_and_required_after_the_workdir() {
let plan = plan_composed_root(
Path::new("/home/u/w"),
&SealedBindSources {
runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
..test_sources()
},
Path::new("/tmp"),
&[],
&[],
&FakeHost::usrmerge(),
);
let bind = RootOp::BindVerified {
source: PathBuf::from(TEST_RUNTIME_DIR),
target: PathBuf::from("/tmp").join(TEST_RUNTIME_DIR.trim_start_matches('/')),
read_only: true,
identity: TEST_RUNTIME_IDENTITY,
};
let at = plan
.steps
.iter()
.position(|step| step.op == bind)
.expect("the runtime directory is bound");
assert!(
plan.steps[at].required,
"a missing runtime directory ends the session"
);
let workdir = plan
.steps
.iter()
.position(|step| {
matches!(&step.op, RootOp::Bind { source, .. } if source == Path::new("/home/u/w"))
})
.unwrap();
assert!(
workdir < at,
"the runtime directory is planned after the workdir"
);
assert_eq!(at, plan.steps.len() - 1);
assert!(plan_for("/home/u/w").steps.iter().all(|step| !matches!(
&step.op,
RootOp::BindVerified { source, .. } if source == Path::new(TEST_RUNTIME_DIR)
)));
}
#[test]
fn the_records_directory_is_bound_read_only_and_required_after_the_runtime_directory() {
const TEST_RECORDS_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x";
const TEST_RECORDS_IDENTITY: (u64, u64) = (7, 77);
let plan = plan_composed_root(
Path::new("/home/u/w"),
&SealedBindSources {
runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
records_dir: Some((Path::new(TEST_RECORDS_DIR), TEST_RECORDS_IDENTITY)),
..test_sources()
},
Path::new("/tmp"),
&[],
&[],
&FakeHost::usrmerge(),
);
let position = |source: &str| {
plan.steps
.iter()
.position(|step| {
matches!(&step.op, RootOp::BindVerified { source: s, read_only: true, .. } if s == Path::new(source))
})
.unwrap_or_else(|| panic!("{source} is bound read-only"))
};
let records = position(TEST_RECORDS_DIR);
assert!(
plan.steps[records].required,
"a missing records directory ends the session"
);
assert!(position(TEST_RUNTIME_DIR) < records);
assert_eq!(
plan.steps[records].op,
RootOp::BindVerified {
source: PathBuf::from(TEST_RECORDS_DIR),
target: PathBuf::from("/tmp").join(TEST_RECORDS_DIR.trim_start_matches('/')),
read_only: true,
identity: TEST_RECORDS_IDENTITY,
}
);
}
#[test]
fn home_folder_is_bound_read_write_and_verified_after_the_records_directory() {
const TEST_RECORDS_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x";
const TEST_RECORDS_IDENTITY: (u64, u64) = (7, 77);
const TEST_HOME_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x/home";
const TEST_HOME_IDENTITY: (u64, u64) = (7, 78);
let plan = plan_composed_root(
Path::new("/home/u/w"),
&SealedBindSources {
runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
records_dir: Some((Path::new(TEST_RECORDS_DIR), TEST_RECORDS_IDENTITY)),
home_dir: Some((Path::new(TEST_HOME_DIR), TEST_HOME_IDENTITY)),
..test_sources()
},
Path::new("/tmp"),
&[],
&[],
&FakeHost::usrmerge(),
);
let position = |source: &str| {
plan.steps
.iter()
.position(|step| {
matches!(&step.op, RootOp::BindVerified { source: s, .. } if s == Path::new(source))
})
.unwrap_or_else(|| panic!("{source} is bound"))
};
let records = position(TEST_RECORDS_DIR);
let home = position(TEST_HOME_DIR);
assert!(records < home, "the home bind comes after the records bind");
assert!(
plan.steps[home].required,
"a missing home folder ends the session"
);
assert_eq!(
plan.steps[home].op,
RootOp::BindVerified {
source: PathBuf::from(TEST_HOME_DIR),
target: PathBuf::from("/tmp").join(TEST_HOME_DIR.trim_start_matches('/')),
read_only: false,
identity: TEST_HOME_IDENTITY,
}
);
assert!(matches!(
plan.steps[records].op,
RootOp::BindVerified {
read_only: true,
..
}
));
}
#[test]
fn nothing_is_bound_beneath_the_childrens_records() {
const TEST_RECORDS_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x";
const TEST_HOME_DIR: &str = "/home/u/.murmur/records/home/u/w/ses_x/home";
const TEST_CHILDREN_RECORDS: &str =
"/home/u/.murmur/records/home/u/.murmur/records/home/u/w/ses_x/children";
let plan = plan_composed_root(
Path::new("/home/u/w"),
&SealedBindSources {
runtime_dir: Some((Path::new(TEST_RUNTIME_DIR), TEST_RUNTIME_IDENTITY)),
records_dir: Some((Path::new(TEST_RECORDS_DIR), (7, 77))),
home_dir: Some((Path::new(TEST_HOME_DIR), (7, 78))),
..test_sources()
},
Path::new("/tmp"),
&[],
&[],
&FakeHost::usrmerge(),
);
let bound: Vec<&Path> = plan
.steps
.iter()
.filter_map(|step| match &step.op {
RootOp::BindVerified { source, .. } | RootOp::Bind { source, .. } => {
Some(source.as_path())
}
_ => None,
})
.collect();
assert!(
bound.contains(&Path::new(TEST_RECORDS_DIR)),
"the plan binds the session's own records: {bound:?}"
);
for source in &bound {
assert!(
!source.starts_with(TEST_CHILDREN_RECORDS)
&& !Path::new(TEST_CHILDREN_RECORDS).starts_with(source),
"{} is bound, which reaches the children's records",
source.display()
);
}
}
#[test]
#[cfg(target_os = "linux")]
fn a_read_only_remount_keeps_the_source_mounts_locked_flags() {
let kept = linux::remount_flags_kept(
libc::ST_NODEV | libc::ST_NOEXEC | libc::ST_NOSUID | libc::ST_RELATIME,
);
for flag in [
libc::MS_NODEV,
libc::MS_NOEXEC,
libc::MS_NOSUID,
libc::MS_RELATIME,
] {
assert_ne!(kept & flag, 0, "{flag:#x}");
}
assert_eq!(kept & libc::MS_STRICTATIME, 0);
let strict = linux::remount_flags_kept(0);
assert_ne!(strict & libc::MS_STRICTATIME, 0);
assert_eq!(strict & (libc::MS_NODEV | libc::MS_NOEXEC), 0);
}
#[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_bound_only_when_it_is_the_directory_the_parent_holds() {
let plan = plan_for("/home/u/w");
assert!(ops(&plan).contains(&RootOp::BindVerified {
source: PathBuf::from(TEST_TMP_SOURCE),
target: PathBuf::from("/tmp/tmp"),
read_only: false,
identity: TEST_TMP_IDENTITY,
}));
assert!(!ops(&plan).iter().any(|op| matches!(
op,
RootOp::Bind { source, .. } if source == Path::new(TEST_TMP_SOURCE)
)));
}
#[test]
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
fn a_verified_bind_source_refuses_a_directory_swapped_in_after_the_descriptor() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("tmp");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&store).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let meta = std::fs::metadata(&store).unwrap();
let identity = (meta.dev(), meta.ino());
let source = std::ffi::CString::new(store.to_str().unwrap()).unwrap();
let fd = unsafe { linux::open_verified_dir(&source, identity) }.expect("same directory");
let mut path = [0u8; 32];
linux::proc_fd_path(fd, &mut path).unwrap();
let resolved = std::fs::canonicalize(
std::ffi::CStr::from_bytes_until_nul(&path)
.unwrap()
.to_str()
.unwrap(),
)
.unwrap();
assert_eq!(resolved, store.canonicalize().unwrap());
unsafe { libc::close(fd) };
std::fs::rename(&store, dir.path().join("moved")).unwrap();
std::os::unix::fs::symlink(&outside, &store).unwrap();
assert!(unsafe { linux::open_verified_dir(&source, identity) }.is_err());
assert_eq!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::ESTALE)
);
}
#[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::BindVerified { 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"),
&test_sources(),
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"),
&test_sources(),
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"),
&test_sources(),
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::BindVerified { 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"),
&test_sources(),
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 every_userns_grant_comes_from_its_own_combination_of_readings() {
let cases = [
((None, None, None), UsernsGrant::ApparmorAbsent),
(
(Some("N"), Some("1"), Some("unconfined")),
UsernsGrant::ApparmorAbsent,
),
(
(Some("Y"), Some("0"), Some("unconfined")),
UsernsGrant::RestrictionDisabledHostWide,
),
(
(Some("1"), None, Some("unconfined")),
UsernsGrant::RestrictionDisabledHostWide,
),
(
(Some("Y"), Some("Y"), Some("mur-sealed (unconfined)")),
UsernsGrant::ProfileConfining,
),
(
(Some("Y"), Some("1"), Some("mur-sealed-home (unconfined)")),
UsernsGrant::ProfileConfining,
),
(
(Some("Y"), Some("1"), Some("mur-sealed-dev (unconfined)")),
UsernsGrant::ProfileConfining,
),
(
(Some("Y"), Some("Y"), Some("unconfined")),
UsernsGrant::Withheld,
),
(
(Some("Y"), Some("1"), Some("firefox (enforce)")),
UsernsGrant::Withheld,
),
((Some("Y"), Some("1"), None), UsernsGrant::Withheld),
];
for ((enabled, restricted, current), expected) in cases {
assert_eq!(
UsernsGrant::from_readings(enabled, restricted, current),
expected,
"enabled={enabled:?} restricted={restricted:?} current={current:?}"
);
}
}
#[test]
fn every_grant_has_a_distinct_wire_name_and_summary() {
let names: Vec<&str> = UsernsGrant::ALL
.iter()
.map(|grant| grant.wire_name())
.collect();
let mut unique = names.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), names.len(), "two grants share a wire name");
assert_eq!(
names,
[
"apparmor_absent",
"restriction_disabled_host_wide",
"profile_confining",
"withheld",
]
);
for grant in UsernsGrant::ALL {
assert_eq!(
serde_json::to_value(grant).unwrap(),
serde_json::Value::String(grant.wire_name().to_string()),
"the serialized form and the wire name must be one string"
);
}
let mut summaries: Vec<&str> = UsernsGrant::ALL.iter().map(|g| g.summary()).collect();
summaries.sort_unstable();
summaries.dedup();
assert_eq!(
summaries.len(),
UsernsGrant::ALL.len(),
"two grants share a summary"
);
assert_eq!(UsernsGrant::default(), UsernsGrant::Withheld);
}
#[test]
fn the_installed_profile_classifier_separates_all_four_outcomes() {
let shipped = std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packaging/apparmor/mur-sealed"
))
.expect("the shipped profile is in the workspace");
assert_eq!(
classify_installed_profile(Ok(shipped)),
InstalledProfileState::Matches
);
let drifted = classify_installed_profile(Ok(b"# an older revision\n".to_vec()));
match drifted {
InstalledProfileState::Drifted { installed_sha256 } => {
assert_ne!(installed_sha256, SEALED_APPARMOR_PROFILE_SHA256);
assert_eq!(installed_sha256.len(), 64);
}
other => panic!("edited bytes must read as drift, got {other:?}"),
}
assert_eq!(
classify_installed_profile(Err(std::io::Error::from(std::io::ErrorKind::NotFound))),
InstalledProfileState::Absent
);
match classify_installed_profile(Err(std::io::Error::from(
std::io::ErrorKind::PermissionDenied,
))) {
InstalledProfileState::Unreadable { error } => assert!(!error.is_empty()),
other => panic!("a read error must stay distinct from absence, got {other:?}"),
}
}
#[test]
fn old_root_names_agree() {
assert_eq!(OLD_ROOT_NAME_C.to_str().unwrap(), OLD_ROOT_NAME);
assert_eq!(
OLD_ROOT_PIVOTED_C.to_str().unwrap(),
format!("/{OLD_ROOT_NAME}")
);
}
#[test]
fn profile_attachment_reports_only_what_the_kernel_said() {
assert_eq!(
classify_profile_attachment(Some("mur-sealed (unconfined)")),
ProfileAttachment::Sealed {
profile: "mur-sealed".to_string()
}
);
assert_eq!(
classify_profile_attachment(Some("mur-sealed-home")),
ProfileAttachment::Sealed {
profile: "mur-sealed-home".to_string()
}
);
assert_eq!(
classify_profile_attachment(Some("snap.chromium.chromium (enforce)")),
ProfileAttachment::Other {
profile: "snap.chromium.chromium".to_string()
}
);
assert_eq!(
classify_profile_attachment(Some("unconfined")),
ProfileAttachment::Unconfined
);
assert_eq!(
classify_profile_attachment(Some("unconfined\n")),
ProfileAttachment::Unconfined
);
assert_eq!(
classify_profile_attachment(None),
ProfileAttachment::NotReported
);
assert_eq!(
classify_profile_attachment(Some(" ")),
ProfileAttachment::NotReported
);
}
#[test]
fn attachment_paths_are_the_ones_the_shipped_profile_declares() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packaging/apparmor/mur-sealed"
);
let profile =
std::fs::read_to_string(path).expect("the shipped profile is in the workspace");
for attachment in SEALED_APPARMOR_ATTACHMENT_PATHS {
assert!(
profile.contains(attachment),
"{attachment} is not an attachment in packaging/apparmor/mur-sealed"
);
}
}
#[test]
fn shipped_profile_digest_constant_matches_the_file() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packaging/apparmor/mur-sealed"
);
let bytes = std::fs::read(path).expect("the shipped profile is in the workspace");
let actual = murmur_artifact::sha256_hex(&bytes);
assert_eq!(
actual, SEALED_APPARMOR_PROFILE_SHA256,
"packaging/apparmor/mur-sealed changed. Update SEALED_APPARMOR_PROFILE_SHA256 in \
crates/capsule-runtime/src/sealed.rs to:\n {actual}"
);
}
#[test]
fn the_apparmor_reason_offers_the_profile_before_the_sysctl() {
let reason = SealedBlocker::AppArmorProfileMissing.reason();
let profile = reason
.find("apparmor_parser -r")
.expect("the profile install command must be named");
let sysctl = reason
.find("kernel.apparmor_restrict_unprivileged_userns=0")
.expect("the fallback must still be named for hosts that cannot load a profile");
assert!(
profile < sysctl,
"the profile install must come first: {reason}"
);
assert!(
reason.contains("LAST RESORT"),
"the sysctl must be labelled a fallback, not offered as a peer: {reason}"
);
assert!(
reason.contains("every program on the machine"),
"the sysctl's host-wide cost must be stated: {reason}"
);
assert!(
reason.contains("scripts/install-dev-apparmor.sh"),
"a checkout build must be told how to get the narrow grant: {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::PivotRootDenied
| SealedBlocker::KernelUnsupported
| SealedBlocker::LandlockUnavailable
| SealedBlocker::NetworkNamespaceUnavailable
| SealedBlocker::ProbeUnavailable => *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 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),
]
);
}
mod shortfall {
use super::super::*;
use crate::namespace_probe::{
MountAttempt, MountAttemptError, MountStep, NamespaceReading,
};
use crate::network_namespace::{CreationStep, NamespaceAttemptError};
fn docker() -> ProfileAttachment {
ProfileAttachment::Other {
profile: "docker-default".to_string(),
}
}
fn created(grant: UsernsGrant, attachment: ProfileAttachment) -> NamespaceReading {
NamespaceReading {
network: Ok(()),
mount: MountAttempt::Created,
sealed_root: MountAttempt::Created,
userns_grant: grant,
attachment,
}
}
fn network_failed(step: CreationStep, errno: i32) -> NamespaceReading {
NamespaceReading {
network: Err(NamespaceAttemptError::Step { step, errno }),
mount: MountAttempt::NotAttempted,
sealed_root: MountAttempt::NotAttempted,
..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
}
}
fn root_failed(step: MountStep, errno: i32) -> NamespaceReading {
NamespaceReading {
sealed_root: MountAttempt::Failed(MountAttemptError::Step { step, errno }),
..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
}
}
#[test]
fn every_failed_attempt_names_its_blocker_call_errno_and_readings() {
use CreationStep as C;
use MountStep as M;
let docker_eperm = |step| NamespaceReading {
attachment: docker(),
..network_failed(step, libc::EPERM)
};
let cases: Vec<(NamespaceReading, SealedBlocker, &str, i32)> = vec![
(
network_failed(C::Unshare, libc::ENOSPC),
SealedBlocker::KernelUnsupported,
C::Unshare.describe(),
libc::ENOSPC,
),
(
network_failed(C::Unshare, libc::EINVAL),
SealedBlocker::KernelUnsupported,
C::Unshare.describe(),
libc::EINVAL,
),
(
network_failed(C::Unshare, libc::ENOSYS),
SealedBlocker::KernelUnsupported,
C::Unshare.describe(),
libc::ENOSYS,
),
(
network_failed(C::Unshare, libc::EUSERS),
SealedBlocker::KernelUnsupported,
C::Unshare.describe(),
libc::EUSERS,
),
(
network_failed(C::Unshare, libc::EPERM),
SealedBlocker::AppArmorProfileMissing,
C::Unshare.describe(),
libc::EPERM,
),
(
network_failed(C::IdentityMaps, libc::EPERM),
SealedBlocker::AppArmorProfileMissing,
C::IdentityMaps.describe(),
libc::EPERM,
),
(
network_failed(C::IdentityMaps, libc::EACCES),
SealedBlocker::AppArmorProfileMissing,
C::IdentityMaps.describe(),
libc::EACCES,
),
(
docker_eperm(C::Unshare),
SealedBlocker::NamespaceCreationDenied,
C::Unshare.describe(),
libc::EPERM,
),
(
docker_eperm(C::IdentityMaps),
SealedBlocker::IdMapDenied,
C::IdentityMaps.describe(),
libc::EPERM,
),
(
network_failed(C::Loopback, libc::EPERM),
SealedBlocker::NetworkNamespaceUnavailable,
C::Loopback.describe(),
libc::EPERM,
),
(
network_failed(C::LocalRoute, libc::EPERM),
SealedBlocker::NetworkNamespaceUnavailable,
C::LocalRoute.describe(),
libc::EPERM,
),
(
network_failed(C::Resolver, libc::EADDRINUSE),
SealedBlocker::NetworkNamespaceUnavailable,
C::Resolver.describe(),
libc::EADDRINUSE,
),
(
network_failed(C::HandOff, libc::EMFILE),
SealedBlocker::NetworkNamespaceUnavailable,
C::HandOff.describe(),
libc::EMFILE,
),
(
root_failed(M::JoinSession, libc::EINVAL),
SealedBlocker::NetworkNamespaceUnavailable,
M::JoinSession.describe(),
libc::EINVAL,
),
(
root_failed(M::UnshareUserMount, libc::EUSERS),
SealedBlocker::KernelUnsupported,
M::UnshareUserMount.describe(),
libc::EUSERS,
),
(
root_failed(M::UnshareUserMount, libc::ENOSPC),
SealedBlocker::KernelUnsupported,
M::UnshareUserMount.describe(),
libc::ENOSPC,
),
(
root_failed(M::UnshareUserMount, libc::EPERM),
SealedBlocker::NamespaceCreationDenied,
M::UnshareUserMount.describe(),
libc::EPERM,
),
(
root_failed(M::IdentityMaps, libc::EPERM),
SealedBlocker::IdMapDenied,
M::IdentityMaps.describe(),
libc::EPERM,
),
(
root_failed(M::MakePrivateNested, libc::EACCES),
SealedBlocker::MountDenied,
M::MakePrivateNested.describe(),
libc::EACCES,
),
(
root_failed(M::Tmpfs, libc::EPERM),
SealedBlocker::MountDenied,
M::Tmpfs.describe(),
libc::EPERM,
),
(
root_failed(M::PivotPrepare, libc::EACCES),
SealedBlocker::PivotRootDenied,
M::PivotPrepare.describe(),
libc::EACCES,
),
(
root_failed(M::PivotRoot, libc::EPERM),
SealedBlocker::PivotRootDenied,
M::PivotRoot.describe(),
libc::EPERM,
),
(
root_failed(M::DetachOldRoot, libc::EINVAL),
SealedBlocker::PivotRootDenied,
M::DetachOldRoot.describe(),
libc::EINVAL,
),
];
for (reading, blocker, call, errno) in cases {
for landlock in [true, false] {
let shortfall = sealed_shortfall(true, landlock, &reading)
.unwrap_or_else(|| panic!("{reading:?} must be refused"));
assert_eq!(shortfall.blocker, blocker, "{reading:?}");
assert_eq!(shortfall.failed_call.as_deref(), Some(call));
assert_eq!(shortfall.errno, Some(errno));
let reason = shortfall.reason();
let name = crate::network_namespace::errno_name(errno).unwrap();
let words = crate::network_namespace::errno_words(errno);
assert!(reason.starts_with(&blocker.reason()), "{reason}");
assert!(
reason
.contains(&format!("The probe's {call} failed with {name} ({words}).")),
"{reason}"
);
let readings = reason.find("Host readings:").expect("readings line");
assert!(reason[readings..].ends_with('.'), "{reason}");
assert!(
!reason[readings..].contains("The probe's"),
"the readings come last: {reason}"
);
}
}
}
#[test]
fn a_probe_that_could_not_run_is_refused_rather_than_assumed() {
for reading in [
NamespaceReading {
network: Err(NamespaceAttemptError::Creator(
"fork() for the namespace creator failed: EAGAIN".to_string(),
)),
mount: MountAttempt::NotAttempted,
sealed_root: MountAttempt::NotAttempted,
..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
},
NamespaceReading {
sealed_root: MountAttempt::Failed(MountAttemptError::Rehearsal(
"fork() for the sealed-root rehearsal failed: EAGAIN".to_string(),
)),
..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
},
] {
let shortfall = sealed_shortfall(true, true, &reading).unwrap();
assert_eq!(shortfall.blocker, SealedBlocker::ProbeUnavailable);
assert_eq!(shortfall.errno, None);
let reason = shortfall.reason();
assert!(
reason.contains("The probe could not run: fork() for the"),
"{reason}"
);
assert!(reason.contains("Host readings:"), "{reason}");
}
}
#[test]
fn the_readings_never_refuse_a_host_whose_attempts_all_succeeded() {
for grant in UsernsGrant::ALL {
for attachment in [
ProfileAttachment::Unconfined,
docker(),
ProfileAttachment::NotReported,
ProfileAttachment::Sealed {
profile: "mur-sealed".to_string(),
},
] {
let reading = created(*grant, attachment);
assert_eq!(sealed_shortfall(true, true, &reading), None, "{reading:?}");
assert_eq!(
sealed_shortfall(true, false, &reading).map(|s| s.blocker),
Some(SealedBlocker::LandlockUnavailable)
);
assert_eq!(
sealed_shortfall(false, true, &reading).map(|s| s.blocker),
Some(SealedBlocker::NotLinux)
);
}
}
}
#[test]
fn a_refusal_under_any_other_grant_is_not_blamed_on_the_profile() {
for grant in [
UsernsGrant::ApparmorAbsent,
UsernsGrant::ProfileConfining,
UsernsGrant::RestrictionDisabledHostWide,
] {
for (step, blocker) in [
(
CreationStep::Unshare,
SealedBlocker::NamespaceCreationDenied,
),
(CreationStep::IdentityMaps, SealedBlocker::IdMapDenied),
] {
let reading = NamespaceReading {
userns_grant: grant,
..network_failed(step, libc::EPERM)
};
assert_eq!(
sealed_shortfall(true, true, &reading).map(|s| s.blocker),
Some(blocker),
"{reading:?}"
);
}
}
}
#[test]
fn the_tmp_attempt_never_affects_sealed() {
let reading = NamespaceReading {
mount: MountAttempt::Failed(MountAttemptError::Step {
step: MountStep::MakePrivate,
errno: libc::EACCES,
}),
..created(UsernsGrant::Withheld, ProfileAttachment::Unconfined)
};
assert_eq!(sealed_shortfall(true, true, &reading), None);
}
#[test]
fn a_shortfall_without_a_failed_attempt_carries_no_readings() {
let reading = created(UsernsGrant::Withheld, ProfileAttachment::Unconfined);
for shortfall in [
sealed_shortfall(false, true, &reading).unwrap(),
sealed_shortfall(true, false, &reading).unwrap(),
] {
assert_eq!(shortfall.reason(), shortfall.blocker.reason());
}
}
#[test]
fn each_blocker_keeps_its_own_remedy() {
let container = SealedBlocker::NamespaceCreationDenied.reason();
assert!(container.contains("--cap-add SYS_ADMIN"), "{container}");
assert!(container.contains("outside the container"), "{container}");
let map = SealedBlocker::IdMapDenied.reason();
assert!(map.contains("uid_map"), "{map}");
assert!(!map.contains("--cap-add"), "{map}");
let pivot = SealedBlocker::PivotRootDenied.reason();
assert!(
pivot.contains("pivot_root(2)") && pivot.contains("seccomp"),
"{pivot}"
);
assert_ne!(pivot, SealedBlocker::MountDenied.reason());
let network = SealedBlocker::NetworkNamespaceUnavailable.reason();
assert!(network.contains("E-CAP-005"), "{network}");
let apparmor = SealedBlocker::AppArmorProfileMissing.reason();
assert!(apparmor.contains("mur-sealed") && apparmor.contains("apparmor_parser -r"));
}
}
}