use std::path::{Path, PathBuf};
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
#[cfg(target_os = "linux")]
use std::time::Duration;
use crate::resources::HostResourceLimits;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const DEFAULT_CGROUP2_MOUNT: &str = "/sys/fs/cgroup";
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const CPU_MAX_PERIOD_US: u64 = 100_000;
pub const SCOPE_SWAP_MAX: &str = "0";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ScopeLimit {
pub(crate) file: &'static str,
pub(crate) value: String,
pub(crate) absent_ok: bool,
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn scope_limits(limits: &HostResourceLimits) -> Vec<ScopeLimit> {
let required = |file, value| ScopeLimit {
file,
value,
absent_ok: false,
};
let mut writes = vec![
required("memory.max", limits.cgroup_memory_bytes.to_string()),
ScopeLimit {
file: "memory.swap.max",
value: SCOPE_SWAP_MAX.to_string(),
absent_ok: true,
},
required("pids.max", limits.cgroup_pids_max.to_string()),
];
if let Some(percent) = limits.cgroup_cpu_percent {
writes.push(required(
"cpu.max",
format!(
"{} {CPU_MAX_PERIOD_US}",
u64::from(percent) * (CPU_MAX_PERIOD_US / 100)
),
));
}
writes
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn declared_scope_limits(
declared: &murmur_artifact::ResourceCapabilities,
) -> Vec<ScopeLimit> {
let required = |file, value| ScopeLimit {
file,
value,
absent_ok: false,
};
let mut writes = Vec::new();
if let Some(bytes) = declared.cgroup_memory_bytes {
writes.push(required("memory.max", bytes.to_string()));
}
if let Some(pids) = declared.cgroup_pids_max {
writes.push(required("pids.max", pids.to_string()));
}
if let Some(percent) = declared.cgroup_cpu_percent {
writes.push(required(
"cpu.max",
format!(
"{} {CPU_MAX_PERIOD_US}",
u64::from(percent) * (CPU_MAX_PERIOD_US / 100)
),
));
}
writes
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SwapControl {
Exposed,
Absent,
Unknown,
}
#[must_use]
pub fn probe_swap_control() -> SwapControl {
#[cfg(not(target_os = "linux"))]
{
SwapControl::Unknown
}
#[cfg(target_os = "linux")]
{
let mount = std::fs::read_to_string("/proc/mounts")
.map(|contents| parse_cgroup2_mount(&contents))
.unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
let Some(own) = std::fs::read_to_string("/proc/self/cgroup")
.ok()
.and_then(|contents| parse_unified_cgroup_path(&contents))
else {
return SwapControl::Unknown;
};
let root = PathBuf::from(mount);
let mut dir = root.join(own.trim_start_matches('/'));
loop {
if dir.join("memory.max").exists() {
return if dir.join("memory.swap.max").exists() {
SwapControl::Exposed
} else {
SwapControl::Absent
};
}
if dir == root || !dir.pop() || !dir.starts_with(&root) {
return SwapControl::Unknown;
}
}
}
}
#[must_use]
pub fn active_swap_areas() -> Option<usize> {
let contents = std::fs::read_to_string("/proc/swaps").ok()?;
Some(count_swap_areas(&contents))
}
fn count_swap_areas(contents: &str) -> usize {
contents
.lines()
.skip(1)
.filter(|line| !line.trim().is_empty())
.count()
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const REQUIRED_CONTROLLERS: &[&str] = &["memory", "pids", "cpu"];
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const SUPERVISOR_LEAF: &str = "murmur-supervisor";
#[derive(Debug)]
pub(crate) struct CgroupScope {
path: PathBuf,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
procs_fd: Option<std::os::fd::OwnedFd>,
occupancy: Arc<Occupancy>,
#[cfg(target_os = "linux")]
watcher: Option<ScopeWatcher>,
}
#[derive(Debug, Default)]
struct Occupancy {
occupants: AtomicU64,
entries: AtomicU64,
}
impl Occupancy {
fn enter(self: &Arc<Self>) -> ScopeOccupancy {
let entries_before = self.entries.load(Ordering::SeqCst);
let others_inside = self.occupants.fetch_add(1, Ordering::SeqCst) > 0;
let sequence = self.entries.fetch_add(1, Ordering::SeqCst);
ScopeOccupancy {
occupancy: Arc::clone(self),
alone_at_entry: !others_inside && sequence == entries_before,
sequence,
}
}
}
#[derive(Debug)]
pub(crate) struct ScopeOccupancy {
occupancy: Arc<Occupancy>,
alone_at_entry: bool,
sequence: u64,
}
impl ScopeOccupancy {
pub(crate) fn exclusive(&self) -> bool {
self.alone_at_entry && self.occupancy.entries.load(Ordering::SeqCst) == self.sequence + 1
}
}
impl Drop for ScopeOccupancy {
fn drop(&mut self) {
self.occupancy.occupants.fetch_sub(1, Ordering::SeqCst);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct CgroupEventCounters {
pub(crate) oom_kill: u64,
pub(crate) pids_max: u64,
}
impl CgroupEventCounters {
pub(crate) fn attribution_since(&self, before: Self) -> Option<&'static str> {
if self.oom_kill > before.oom_kill {
return Some("cgroup_memory_bytes");
}
if self.pids_max > before.pids_max {
return Some("cgroup_pids_max");
}
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum IoMaxStatus {
Enforced,
Unavailable,
NotRequired,
#[default]
NotProbed,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
pub struct IoMaxReport {
pub declared_bytes_per_sec: u64,
pub status: IoMaxStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl IoMaxReport {
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn enforced(declared_bytes_per_sec: u64) -> Self {
Self {
declared_bytes_per_sec,
status: IoMaxStatus::Enforced,
reason: None,
}
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn unavailable(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
Self {
declared_bytes_per_sec,
status: IoMaxStatus::Unavailable,
reason: Some(reason.into()),
}
}
#[must_use]
pub fn no_scope_required(declared_bytes_per_sec: u64) -> Self {
Self::not_required(declared_bytes_per_sec, NO_SCOPE_REASON)
}
fn not_required(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
Self {
declared_bytes_per_sec,
status: IoMaxStatus::NotRequired,
reason: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CgroupScopeStatus {
Delegated,
Unavailable,
NotRequired,
#[default]
NotProbed,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
pub struct CgroupScopeReport {
pub status: CgroupScopeStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub errno: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl CgroupScopeReport {
fn delegated() -> Self {
Self {
status: CgroupScopeStatus::Delegated,
errno: None,
reason: None,
}
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn unavailable(error: &CgroupError) -> Self {
Self {
status: CgroupScopeStatus::Unavailable,
errno: error.errno.map(crate::network_namespace::errno_label),
reason: Some(error.message.clone()),
}
}
fn not_required(reason: &str) -> Self {
Self {
status: CgroupScopeStatus::NotRequired,
errno: None,
reason: Some(reason.to_string()),
}
}
#[must_use]
pub fn no_scope_required() -> Self {
Self::not_required(NO_CGROUP_SCOPE_REASON)
}
fn of_prepared(scope: Option<&Arc<CgroupScope>>, required: bool) -> Self {
match (scope, required) {
(Some(_), _) => Self::delegated(),
(None, false) => Self::no_scope_required(),
(None, true) => Self::not_required(NOT_LINUX_CGROUP_REASON),
}
}
}
#[must_use]
pub fn render_cgroup_scope(report: &CgroupScopeReport) -> String {
let status = match report.status {
CgroupScopeStatus::Delegated => "delegated",
CgroupScopeStatus::Unavailable => "unavailable",
CgroupScopeStatus::NotRequired => "not-required",
CgroupScopeStatus::NotProbed => "not-probed",
};
match (&report.errno, &report.reason) {
(Some(errno), Some(reason)) => {
format!("cgroup scope: {status} ({errno}) \u{2014} {reason}")
}
(Some(errno), None) => format!("cgroup scope: {status} ({errno})"),
(None, Some(reason)) => format!("cgroup scope: {status} \u{2014} {reason}"),
(None, None) => format!("cgroup scope: {status}"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CgroupError {
pub(crate) message: String,
pub(crate) errno: Option<i32>,
}
impl CgroupError {
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn io(message: String, error: &std::io::Error) -> Self {
Self {
message,
errno: error.raw_os_error(),
}
}
}
impl std::fmt::Display for CgroupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl From<String> for CgroupError {
fn from(message: String) -> Self {
Self {
message,
errno: None,
}
}
}
impl From<CgroupError> for String {
fn from(error: CgroupError) -> Self {
error.message
}
}
const NO_CGROUP_SCOPE_REASON: &str = "this capsule declares no way to run a subprocess, so \
a launch creates no cgroup scope for it";
const NOT_LINUX_CGROUP_REASON: &str =
"cgroups are a Linux mechanism and this host has none, so a launch creates no cgroup scope";
const NO_SCOPE_REASON: &str = "this capsule declares no way to run a subprocess, so it is \
given no cgroup scope and there is no process tree for an I/O ceiling to apply to";
const UNCONFINED_NO_CGROUP_SCOPE_REASON: &str = "this session runs unconfined and \
capabilities.resources declares no cgroup field, so a launch creates no cgroup scope for it";
const UNCONFINED_NO_SCOPE_REASON: &str = "this session runs unconfined and \
capabilities.resources declares no cgroup field, so it is given no cgroup scope and no \
I/O ceiling applies";
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const UNCONFINED_IO_UNDECLARED_REASON: &str = "this session runs unconfined and \
capabilities.resources declares no cgroup_io_bytes_per_sec, so its cgroup scope writes no \
io.max";
#[cfg_attr(target_os = "linux", allow(dead_code))]
const NOT_LINUX_REASON: &str =
"cgroups are a Linux mechanism and this host has none, so no io.max ceiling can exist here";
const IO_MAX_UNAVAILABLE_WARNING: &str = "the declared \
capabilities.resources.cgroup_io_bytes_per_sec ceiling did not apply to this session's cgroup \
scope, so this capsule's subprocess tree has no I/O bandwidth bound";
pub(crate) fn io_max_warning(
report: &IoMaxReport,
cpu_capped: bool,
) -> Option<(&'static str, String)> {
if report.status != IoMaxStatus::Unavailable {
return None;
}
let declared = report.declared_bytes_per_sec;
let reason = report.reason.as_deref().unwrap_or("no reason reported");
let enforced = if cpu_capped {
"memory.max, pids.max and cpu.max"
} else {
"memory.max and pids.max"
};
Some((
murmur_artifact::W_SEC_021,
format!(
"{IO_MAX_UNAVAILABLE_WARNING}; {enforced} are still enforced on the scope \
(declared {declared} bytes/s): {reason}"
),
))
}
pub(crate) fn warn_for_unenforced_io_max(
workdir: &crate::workdir_fs::WorkdirRoot,
report: &IoMaxReport,
cpu_capped: bool,
) {
if let Some((code, message)) = io_max_warning(report, cpu_capped) {
let link = murmur_artifact::security_warning_link(code);
crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
crate::agent::append_bootstrap_log(
workdir,
&format!("[capability-policy] warning[{code}]: {message} ({link})"),
);
}
}
#[derive(Debug)]
pub(crate) struct PreparedScope {
pub(crate) scope: Option<Arc<CgroupScope>>,
pub(crate) io_max: IoMaxReport,
pub(crate) cgroup_scope: CgroupScopeReport,
}
pub(crate) fn prepare_scope(
required: bool,
limits: &HostResourceLimits,
session_id: &str,
workdir: &Path,
) -> Result<PreparedScope, String> {
if !required {
return Ok(PreparedScope {
scope: None,
io_max: IoMaxReport::no_scope_required(limits.cgroup_io_bytes_per_sec),
cgroup_scope: CgroupScopeReport::of_prepared(None, false),
});
}
#[cfg(target_os = "linux")]
{
#[cfg(test)]
if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
return Err("forced: delegation refused by the test seam".to_string());
}
let (scope, io_max) = CgroupScope::create(
&scope_limits(limits),
IoMaxWrite::Declared(limits.cgroup_io_bytes_per_sec),
session_id,
workdir,
)?;
let scope = Arc::new(scope);
Ok(PreparedScope {
cgroup_scope: CgroupScopeReport::of_prepared(Some(&scope), true),
scope: Some(scope),
io_max,
})
}
#[cfg(not(target_os = "linux"))]
{
let _ = (session_id, workdir);
Ok(PreparedScope {
scope: None,
io_max: IoMaxReport::not_required(limits.cgroup_io_bytes_per_sec, NOT_LINUX_REASON),
cgroup_scope: CgroupScopeReport::of_prepared(None, true),
})
}
}
pub(crate) fn prepare_policy_scope(
policy: &crate::types::CapabilityPolicy,
session_id: &str,
workdir: &Path,
) -> Result<PreparedScope, String> {
let required = requires_process_bounding(policy);
if policy.containment_floor != murmur_artifact::ContainmentClass::Unconfined {
return prepare_scope(required, &policy.resources, session_id, workdir);
}
let reported_bytes_per_sec = policy.resources.cgroup_io_bytes_per_sec;
if !required {
let (io_max, cgroup_scope) = unrequired_scope_reports(policy);
return Ok(PreparedScope {
scope: None,
io_max,
cgroup_scope,
});
}
#[cfg(target_os = "linux")]
{
#[cfg(test)]
if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
return Err("forced: delegation refused by the test seam".to_string());
}
let declared = &policy.declared_resources;
let io = match declared.cgroup_io_bytes_per_sec {
Some(bytes_per_sec) => IoMaxWrite::Declared(bytes_per_sec),
None => IoMaxWrite::Undeclared(reported_bytes_per_sec),
};
let (scope, io_max) =
CgroupScope::create(&declared_scope_limits(declared), io, session_id, workdir)?;
let scope = Arc::new(scope);
Ok(PreparedScope {
cgroup_scope: CgroupScopeReport::of_prepared(Some(&scope), true),
scope: Some(scope),
io_max,
})
}
#[cfg(not(target_os = "linux"))]
{
let _ = (session_id, workdir);
Ok(PreparedScope {
scope: None,
io_max: IoMaxReport::not_required(reported_bytes_per_sec, NOT_LINUX_REASON),
cgroup_scope: CgroupScopeReport::of_prepared(None, true),
})
}
}
fn unrequired_scope_reports(
policy: &crate::types::CapabilityPolicy,
) -> (IoMaxReport, CgroupScopeReport) {
let reported_bytes_per_sec = policy.resources.cgroup_io_bytes_per_sec;
let has_route = crate::runtime::has_subprocess_route_at_staging(policy);
if policy.containment_floor == murmur_artifact::ContainmentClass::Unconfined && has_route {
return (
IoMaxReport::not_required(reported_bytes_per_sec, UNCONFINED_NO_SCOPE_REASON),
CgroupScopeReport::not_required(UNCONFINED_NO_CGROUP_SCOPE_REASON),
);
}
(
IoMaxReport::no_scope_required(reported_bytes_per_sec),
CgroupScopeReport::no_scope_required(),
)
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[derive(Debug, Clone, Copy)]
enum IoMaxWrite {
Declared(u64),
Undeclared(u64),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopeProbe {
pub cgroup_scope: CgroupScopeReport,
pub io_max: IoMaxReport,
}
#[must_use]
pub fn probe_scope(required: bool, limits: &HostResourceLimits, workdir: &Path) -> ScopeProbe {
let declared = limits.cgroup_io_bytes_per_sec;
if !required {
return ScopeProbe {
cgroup_scope: CgroupScopeReport::no_scope_required(),
io_max: IoMaxReport::no_scope_required(declared),
};
}
#[cfg(target_os = "linux")]
{
let base = match delegated_base() {
Ok(base) => base,
Err(error) => {
return ScopeProbe {
cgroup_scope: CgroupScopeReport::unavailable(&error),
io_max: IoMaxReport::unavailable(
declared,
format!("no delegated cgroup base to write io.max into: {error}"),
),
}
}
};
let path = base.join(scope_dir_name("explain-scope"));
if let Err(error) = create_scope_dir(&path) {
return ScopeProbe {
cgroup_scope: CgroupScopeReport::unavailable(&scope_dir_refusal(&path, &error)),
io_max: IoMaxReport::unavailable(
declared,
format!(
"could not create a throwaway cgroup at {}: {error}",
path.display()
),
),
};
}
let io_max = write_io_max(&path, declared, workdir);
let _ = std::fs::remove_dir(&path);
ScopeProbe {
cgroup_scope: CgroupScopeReport::delegated(),
io_max,
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = workdir;
ScopeProbe {
cgroup_scope: CgroupScopeReport::not_required(NOT_LINUX_CGROUP_REASON),
io_max: IoMaxReport::not_required(declared, NOT_LINUX_REASON),
}
}
}
#[cfg(target_os = "linux")]
fn create_scope_dir(path: &Path) -> std::io::Result<()> {
match std::fs::create_dir(path) {
Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => Err(error),
_ => Ok(()),
}
}
#[cfg(target_os = "linux")]
fn scope_dir_refusal(path: &Path, error: &std::io::Error) -> CgroupError {
CgroupError::io(
format!(
"could not create cgroup scope at {}: {error}",
path.display()
),
error,
)
}
#[must_use]
pub fn explain_scope_probe(policy: &crate::types::CapabilityPolicy, workdir: &Path) -> ScopeProbe {
let required = requires_process_bounding(policy);
if policy.containment_floor != murmur_artifact::ContainmentClass::Unconfined {
return probe_scope(required, &policy.resources, workdir);
}
if !required {
let (io_max, cgroup_scope) = unrequired_scope_reports(policy);
return ScopeProbe {
cgroup_scope,
io_max,
};
}
let declared_io = policy.declared_resources.cgroup_io_bytes_per_sec;
let mut probe = probe_scope(
true,
&HostResourceLimits {
cgroup_io_bytes_per_sec: declared_io
.unwrap_or(policy.resources.cgroup_io_bytes_per_sec),
..policy.resources
},
workdir,
);
if cfg!(target_os = "linux") && declared_io.is_none() {
probe.io_max = IoMaxReport::not_required(
policy.resources.cgroup_io_bytes_per_sec,
UNCONFINED_IO_UNDECLARED_REASON,
);
}
probe
}
pub fn requires_process_bounding(policy: &crate::types::CapabilityPolicy) -> bool {
let has_route = crate::runtime::has_subprocess_route_at_staging(policy);
if policy.containment_floor == murmur_artifact::ContainmentClass::Unconfined {
return has_route && crate::resources::declares_cgroup_limit(&policy.declared_resources);
}
has_route
}
impl CgroupScope {
pub(crate) fn path(&self) -> &Path {
&self.path
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[allow(unsafe_code)]
pub(crate) fn join_current_process(&self) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
let Some(fd) = self.procs_fd.as_ref() else {
return Ok(());
};
let pid = unsafe { libc::getpid() };
let mut buf = [0u8; 24];
let written = format_pid(pid, &mut buf);
let mut offset = 0;
while offset < written {
let rc = unsafe {
libc::write(
fd.as_raw_fd(),
buf[offset..written].as_ptr().cast::<libc::c_void>(),
written - offset,
)
};
if rc < 0 {
let error = std::io::Error::last_os_error();
if error.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(error);
}
offset += rc as usize;
}
Ok(())
}
pub(crate) fn enter(&self) -> ScopeOccupancy {
self.occupancy.enter()
}
pub(crate) fn places_subprocesses(&self) -> bool {
self.procs_fd.is_some()
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn event_counters(&self) -> CgroupEventCounters {
CgroupEventCounters {
oom_kill: read_event_counter(&self.path.join("memory.events"), "oom_kill"),
pids_max: read_event_counter(&self.path.join("pids.events"), "max"),
}
}
}
impl CgroupScope {
pub(crate) fn kill_all(&self) -> std::io::Result<()> {
if self.path.as_os_str().is_empty() {
return Ok(());
}
#[cfg(target_os = "linux")]
{
#[cfg(test)]
let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
#[cfg(not(test))]
let forced = false;
if !forced {
match write_existing(&self.path.join("cgroup.kill"), "1") {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
written => return written,
}
}
self.kill_by_freezing()
}
#[cfg(not(target_os = "linux"))]
Ok(())
}
pub(crate) fn wait_until_empty(&self, timeout: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
loop {
if self.member_pids().is_empty() {
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(KILL_POLL_INTERVAL);
}
}
pub(crate) fn member_pids(&self) -> Vec<i32> {
if self.path.as_os_str().is_empty() {
return Vec::new();
}
std::fs::read_to_string(self.path.join("cgroup.procs"))
.map(|procs| {
procs
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect()
})
.unwrap_or_default()
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
fn kill_by_freezing(&self) -> std::io::Result<()> {
let freeze = self.path.join("cgroup.freeze");
let deadline = std::time::Instant::now() + KILL_FALLBACK_DEADLINE;
loop {
let pids = self.member_pids();
if pids.is_empty() {
return Ok(());
}
let _ = write_existing(&freeze, "1");
for pid in pids {
unsafe { libc::kill(pid, libc::SIGKILL) };
}
let _ = write_existing(&freeze, "0");
if std::time::Instant::now() >= deadline {
return Err(std::io::Error::other(format!(
"processes were still in {} after {} ms of SIGKILL",
self.path.display(),
KILL_FALLBACK_DEADLINE.as_millis()
)));
}
std::thread::sleep(KILL_POLL_INTERVAL);
}
}
}
const KILL_FALLBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
const KILL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
#[cfg(target_os = "linux")]
fn write_existing(path: &Path, value: &str) -> std::io::Result<()> {
use std::io::Write as _;
let file = std::fs::OpenOptions::new().write(true).open(path)?;
(&file).write_all(value.as_bytes())
}
#[cfg(all(test, target_os = "linux"))]
thread_local! {
pub(crate) static FORCE_KILL_FALLBACK: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
#[cfg(all(test, target_os = "linux"))]
thread_local! {
pub(crate) static FORCE_DELEGATION_FAILURE: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };
}
impl Drop for CgroupScope {
fn drop(&mut self) {
if self.path.as_os_str().is_empty() {
return;
}
if !self.member_pids().is_empty() {
let _ = self.kill_all();
self.wait_until_empty(KILL_FALLBACK_DEADLINE);
}
self.procs_fd = None;
#[cfg(target_os = "linux")]
{
self.watcher = None;
}
if let Err(error) = std::fs::remove_dir(&self.path) {
crate::runtime_err!(
"[capsule-runtime] note: could not remove cgroup scope {} ({error}); \
it will be reclaimed once its last task exits",
self.path.display()
);
}
}
}
#[cfg(target_os = "linux")]
#[derive(Debug)]
struct ScopeWatcher {
lifeline: std::sync::Mutex<Option<std::os::fd::OwnedFd>>,
pid: libc::pid_t,
}
#[cfg(target_os = "linux")]
#[derive(Clone, Copy)]
struct WatcherKill {
kill: libc::c_int,
freeze: libc::c_int,
procs: libc::c_int,
}
#[cfg(target_os = "linux")]
impl ScopeWatcher {
#[allow(unsafe_code)]
fn spawn(scope: &Path) -> std::io::Result<Self> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
#[cfg(test)]
let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
#[cfg(not(test))]
let forced = false;
let kill = if forced {
None
} else {
open_if_present(&scope.join("cgroup.kill"), libc::O_WRONLY)?
};
let (freeze, procs) = match kill {
Some(_) => (None, None),
None => (
open_if_present(&scope.join("cgroup.freeze"), libc::O_WRONLY)?,
Some(open_with(&scope.join("cgroup.procs"), libc::O_RDONLY)?),
),
};
let devnull = open_with(Path::new("/dev/null"), libc::O_RDWR)?;
let mut ends = [0 as libc::c_int; 2];
if unsafe { libc::pipe2(ends.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
return Err(std::io::Error::last_os_error());
}
let (read_end, write_end) =
unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
let raw = |fd: &Option<OwnedFd>| fd.as_ref().map_or(-1, AsRawFd::as_raw_fd);
let plan = WatcherKill {
kill: raw(&kill),
freeze: raw(&freeze),
procs: raw(&procs),
};
let mut keep = [-1 as libc::c_int; 7];
let mut kept = 0;
for fd in [
0,
1,
2,
read_end.as_raw_fd(),
plan.kill,
plan.freeze,
plan.procs,
] {
if fd >= 0 {
keep[kept] = fd;
kept += 1;
}
}
keep[..kept].sort_unstable();
let devnull_fd = devnull.as_raw_fd();
let read_fd = read_end.as_raw_fd();
let deadline_ns = KILL_FALLBACK_DEADLINE.as_nanos() as i64;
let pid = unsafe { libc::fork() };
if pid < 0 {
return Err(std::io::Error::last_os_error());
}
if pid == 0 {
unsafe { watch(devnull_fd, &keep[..kept], read_fd, plan, deadline_ns) }
}
Ok(Self {
lifeline: std::sync::Mutex::new(Some(write_end)),
pid,
})
}
#[cfg(test)]
fn cut_lifeline(&self) {
self.lifeline
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
}
}
#[cfg(target_os = "linux")]
impl Drop for ScopeWatcher {
#[allow(unsafe_code)]
fn drop(&mut self) {
self.lifeline
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let mut status = 0;
loop {
let waited = unsafe { libc::waitpid(self.pid, &mut status, 0) };
if waited >= 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
return;
}
}
}
}
#[cfg(target_os = "linux")]
fn open_with(path: &Path, flags: libc::c_int) -> std::io::Result<std::os::fd::OwnedFd> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(flags & libc::O_ACCMODE != libc::O_WRONLY)
.write(flags & libc::O_ACCMODE != libc::O_RDONLY)
.custom_flags(libc::O_CLOEXEC)
.open(path)
.map(std::os::fd::OwnedFd::from)
}
#[cfg(target_os = "linux")]
fn open_if_present(
path: &Path,
flags: libc::c_int,
) -> std::io::Result<Option<std::os::fd::OwnedFd>> {
match open_with(path, flags) {
Ok(fd) => Ok(Some(fd)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
unsafe fn watch(
devnull: libc::c_int,
keep: &[libc::c_int],
read_fd: libc::c_int,
plan: WatcherKill,
deadline_ns: i64,
) -> ! {
unsafe {
for target in 0..3 {
libc::dup2(devnull, target);
}
let mut low: libc::c_uint = 0;
for &fd in keep {
let fd = fd as libc::c_uint;
if fd > low {
close_range(low, fd - 1);
}
low = fd + 1;
}
close_range(low, libc::c_uint::MAX);
libc::setpgid(0, 0);
let mut ignore: libc::sigaction = std::mem::zeroed();
ignore.sa_sigaction = libc::SIG_IGN;
for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGPIPE] {
libc::sigaction(signal, &ignore, std::ptr::null_mut());
}
let mut byte = [0u8; 1];
loop {
let read = libc::read(read_fd, byte.as_mut_ptr().cast(), 1);
if read == 0 {
break;
}
if read < 0 && *libc::__errno_location() != libc::EINTR {
break;
}
}
kill_scope(plan, deadline_ns);
libc::_exit(0)
}
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
pub(crate) unsafe fn close_range(low: libc::c_uint, high: libc::c_uint) {
unsafe {
if libc::syscall(libc::SYS_close_range, low, high, 0 as libc::c_uint) == 0 {
return;
}
let mut limit: libc::rlimit = std::mem::zeroed();
let ceiling = if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) == 0 {
limit.rlim_cur.min(libc::c_uint::MAX as libc::rlim_t) as libc::c_uint
} else {
1 << 20
};
let mut fd = low;
while fd <= high && fd < ceiling {
libc::close(fd as libc::c_int);
fd += 1;
}
}
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
unsafe fn kill_scope(plan: WatcherKill, deadline_ns: i64) {
unsafe {
if plan.kill >= 0 {
write_control(plan.kill, b"1");
return;
}
let start = monotonic_ns();
loop {
if plan.freeze >= 0 {
write_control(plan.freeze, b"1");
}
let signalled = signal_listed(plan.procs);
if plan.freeze >= 0 {
write_control(plan.freeze, b"0");
}
if signalled == 0 || monotonic_ns() - start >= deadline_ns {
return;
}
let pause = libc::timespec {
tv_sec: 0,
tv_nsec: KILL_POLL_INTERVAL.as_nanos() as libc::c_long,
};
libc::nanosleep(&pause, std::ptr::null_mut());
}
}
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
unsafe fn write_control(fd: libc::c_int, value: &[u8]) {
unsafe {
libc::lseek(fd, 0, libc::SEEK_SET);
libc::write(fd, value.as_ptr().cast(), value.len());
}
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
unsafe fn signal_listed(procs: libc::c_int) -> usize {
unsafe {
if procs < 0 || libc::lseek(procs, 0, libc::SEEK_SET) < 0 {
return 0;
}
let mut buf = [0u8; 512];
let mut pid: libc::pid_t = 0;
let mut digits = false;
let mut signalled = 0;
loop {
let read = libc::read(procs, buf.as_mut_ptr().cast(), buf.len());
if read < 0 && *libc::__errno_location() == libc::EINTR {
continue;
}
if read <= 0 {
break;
}
for &byte in &buf[..read as usize] {
if byte.is_ascii_digit() {
pid = pid
.saturating_mul(10)
.saturating_add(libc::pid_t::from(byte - b'0'));
digits = true;
} else {
if digits && pid > 0 {
libc::kill(pid, libc::SIGKILL);
signalled += 1;
}
pid = 0;
digits = false;
}
}
}
if digits && pid > 0 {
libc::kill(pid, libc::SIGKILL);
signalled += 1;
}
signalled
}
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
unsafe fn monotonic_ns() -> i64 {
unsafe {
let mut now: libc::timespec = std::mem::zeroed();
libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now);
now.tv_sec as i64 * 1_000_000_000 + now.tv_nsec as i64
}
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn format_pid(pid: i32, buf: &mut [u8; 24]) -> usize {
let mut digits = [0u8; 20];
let mut count = 0;
let mut value = pid.unsigned_abs() as u64;
if value == 0 {
digits[0] = b'0';
count = 1;
}
while value > 0 {
digits[count] = b'0' + (value % 10) as u8;
value /= 10;
count += 1;
}
let mut written = 0;
for index in (0..count).rev() {
buf[written] = digits[index];
written += 1;
}
buf[written] = b'\n';
written + 1
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn read_event_counter(path: &Path, key: &str) -> u64 {
let Ok(contents) = std::fs::read_to_string(path) else {
return 0;
};
parse_event_counter(&contents, key)
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn parse_event_counter(contents: &str, key: &str) -> u64 {
for line in contents.lines() {
let mut parts = line.split_whitespace();
if parts.next() == Some(key) {
return parts
.next()
.and_then(|value| value.parse().ok())
.unwrap_or(0);
}
}
0
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn scope_dir_name(session_id: &str) -> String {
static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let sanitized: String = session_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
let pid = std::process::id();
if sanitized.is_empty() {
format!("murmur-{pid}-{serial}")
} else {
format!("murmur-{sanitized}-{pid}-{serial}")
}
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn parse_unified_cgroup_path(contents: &str) -> Option<String> {
for line in contents.lines() {
let mut parts = line.splitn(3, ':');
let (Some("0"), Some(""), Some(path)) = (parts.next(), parts.next(), parts.next()) else {
continue;
};
if path.starts_with('/') {
return Some(path.to_string());
}
}
None
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn parse_cgroup2_mount(contents: &str) -> String {
for line in contents.lines() {
let mut parts = line.split_whitespace();
let (Some(_source), Some(target), Some(fstype)) =
(parts.next(), parts.next(), parts.next())
else {
continue;
};
if fstype == "cgroup2" {
return target.to_string();
}
}
DEFAULT_CGROUP2_MOUNT.to_string()
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn missing_controllers(subtree_control: &str, wanted: &[&str]) -> Vec<String> {
let enabled: Vec<&str> = subtree_control.split_whitespace().collect();
wanted
.iter()
.filter(|controller| !enabled.contains(*controller))
.map(|controller| (*controller).to_string())
.collect()
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn device_major_minor(dev: u64) -> (u64, u64) {
let major = ((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff);
let minor = (dev & 0xff) | ((dev >> 12) & !0xff);
(major, minor)
}
#[cfg(target_os = "linux")]
impl CgroupScope {
fn create(
limits: &[ScopeLimit],
io: IoMaxWrite,
session_id: &str,
workdir: &Path,
) -> Result<(Self, IoMaxReport), String> {
let base = delegated_base()?;
let path = base.join(scope_dir_name(session_id));
create_scope_dir(&path).map_err(|error| scope_dir_refusal(&path, &error))?;
let scope = Self {
path,
procs_fd: None,
occupancy: Arc::default(),
watcher: None,
};
for limit in limits {
scope.write_limit(limit)?;
}
let io_max = match io {
IoMaxWrite::Declared(bytes_per_sec) => scope.apply_io_max(bytes_per_sec, workdir),
IoMaxWrite::Undeclared(bytes_per_sec) => {
IoMaxReport::not_required(bytes_per_sec, UNCONFINED_IO_UNDECLARED_REASON)
}
};
let procs_fd = open_write_only(&scope.path.join("cgroup.procs")).map_err(|error| {
format!(
"could not open {}/cgroup.procs for subprocess placement: {error}",
scope.path.display()
)
})?;
let watcher = ScopeWatcher::spawn(&scope.path).map_err(|error| {
format!(
"could not start the watcher that kills {} if the runtime dies: {error}",
scope.path.display()
)
})?;
Ok((
Self {
path: scope.take_path(),
procs_fd: Some(procs_fd),
occupancy: Arc::default(),
watcher: Some(watcher),
},
io_max,
))
}
fn take_path(mut self) -> PathBuf {
let path = std::mem::take(&mut self.path);
path
}
fn write_limit(&self, limit: &ScopeLimit) -> Result<(), String> {
let path = self.path.join(limit.file);
let value = &limit.value;
match write_existing(&path, &format!("{value}\n")) {
Err(error) if limit.absent_ok && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
written => written
.map_err(|error| format!("could not write {value} to {}: {error}", path.display())),
}
}
fn apply_io_max(&self, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
write_io_max(&self.path, bytes_per_sec, workdir)
}
}
#[cfg(target_os = "linux")]
fn write_io_max(scope_path: &Path, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
let (major, minor) = match io_max_device(workdir) {
Ok(device) => device,
Err(reason) => return IoMaxReport::unavailable(bytes_per_sec, reason),
};
let value = format!("{major}:{minor} rbps={bytes_per_sec} wbps={bytes_per_sec}");
match std::fs::write(scope_path.join("io.max"), format!("{value}\n")) {
Ok(()) => IoMaxReport::enforced(bytes_per_sec),
Err(error) => IoMaxReport::unavailable(
bytes_per_sec,
format!("writing `{value}` to the scope's io.max: {error}"),
),
}
}
#[cfg(target_os = "linux")]
fn io_max_device(path: &Path) -> Result<(u64, u64), String> {
use std::os::linux::fs::MetadataExt;
let Some(existing) = nearest_existing_ancestor(path) else {
return Err(format!(
"could not stat {} or any ancestor of it to find its backing device",
path.display()
));
};
let metadata = existing.metadata().map_err(|error| {
format!(
"could not stat {} to find its backing device: {error}",
existing.display()
)
})?;
let (major, minor) = device_major_minor(metadata.st_dev());
let (major, minor) = if major == 0 {
mount_source_device(existing)?
} else {
(major, minor)
};
Ok(whole_disk_device(major, minor))
}
#[cfg(target_os = "linux")]
fn mount_source_device(path: &Path) -> Result<(u64, u64), String> {
use std::os::unix::fs::{FileTypeExt, MetadataExt};
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(|error| {
format!(
"reading /proc/self/mountinfo to find the device behind {}: {error}",
resolved.display()
)
})?;
let Some(mount) = mount_covering(&mountinfo, &resolved) else {
return Err(format!(
"no mount in /proc/self/mountinfo covers {}, so its backing device is unknown",
resolved.display()
));
};
match std::fs::metadata(&mount.source) {
Ok(metadata) if metadata.file_type().is_block_device() => {
Ok(device_major_minor(metadata.rdev()))
}
_ => Err(format!(
"the filesystem mounted at {} is backed by `{}`, which is not a block device, so no \
io.max ceiling can name one",
mount.mount_point.display(),
mount.source.display()
)),
}
}
#[cfg(target_os = "linux")]
fn whole_disk_device(major: u64, minor: u64) -> (u64, u64) {
let device = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
if !device.join("partition").exists() {
return (major, minor);
}
std::fs::read_to_string(device.join("../dev"))
.ok()
.and_then(|contents| parse_dev_line(&contents))
.unwrap_or((major, minor))
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn parse_dev_line(contents: &str) -> Option<(u64, u64)> {
let (major, minor) = contents.trim().split_once(':')?;
Some((major.parse().ok()?, minor.parse().ok()?))
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[derive(Debug, PartialEq, Eq)]
struct MountEntry {
mount_point: PathBuf,
source: PathBuf,
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn mount_covering(mountinfo: &str, path: &Path) -> Option<MountEntry> {
let mut best: Option<(usize, MountEntry)> = None;
for line in mountinfo.lines() {
let Some(entry) = parse_mount_entry(line) else {
continue;
};
if !path.starts_with(&entry.mount_point) {
continue;
}
let depth = entry.mount_point.components().count();
if best.as_ref().is_none_or(|(best, _)| depth >= *best) {
best = Some((depth, entry));
}
}
best.map(|(_, entry)| entry)
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn parse_mount_entry(line: &str) -> Option<MountEntry> {
let (mounted, backing) = line.split_once(" - ")?;
let mount_point = mounted.split_whitespace().nth(4)?;
let source = backing.split_whitespace().nth(1)?;
Some(MountEntry {
mount_point: unescape_octal(mount_point),
source: unescape_octal(source),
})
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn unescape_octal(field: &str) -> PathBuf {
use std::os::unix::ffi::OsStringExt;
let bytes = field.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
let escape = bytes[index] == b'\\'
&& index + 3 < bytes.len()
&& bytes[index + 1..index + 4]
.iter()
.all(|byte| (b'0'..=b'7').contains(byte));
if escape {
let digits = &field[index + 1..index + 4];
out.push(u8::from_str_radix(digits, 8).expect("three octal digits"));
index += 4;
} else {
out.push(bytes[index]);
index += 1;
}
}
PathBuf::from(std::ffi::OsString::from_vec(out))
}
#[cfg(target_os = "linux")]
fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
let mut candidate = Some(path);
while let Some(current) = candidate {
if current.exists() {
return Some(current);
}
candidate = current.parent();
}
None
}
#[cfg(all(test, target_os = "linux"))]
pub(crate) static DELEGATED_BASE_RESOLUTION_SPAWNS: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
pub(crate) fn resolve_delegated_base_in_background() {
#[cfg(target_os = "linux")]
{
#[cfg(test)]
DELEGATED_BASE_RESOLUTION_SPAWNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let _ = std::thread::Builder::new()
.name("murmur-cgroup-base".to_string())
.spawn(|| {
let _ = delegated_base();
});
}
}
#[cfg(target_os = "linux")]
fn delegated_base() -> Result<PathBuf, CgroupError> {
static BASE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
let mut cached = BASE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(base) = cached.as_ref() {
return Ok(base.clone());
}
move_self_into_own_systemd_scope();
let base = probe_delegation()?;
enable_controllers(&base)?;
*cached = Some(base.clone());
Ok(base)
}
#[cfg(target_os = "linux")]
fn move_self_into_own_systemd_scope() {
static ATTEMPTED: std::sync::Once = std::sync::Once::new();
ATTEMPTED.call_once(|| {
let _ = request_transient_scope(&transient_scope_unit_name());
});
}
#[cfg(target_os = "linux")]
fn transient_scope_unit_name() -> String {
static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("murmur-{}-{serial}.scope", std::process::id())
}
#[cfg(target_os = "linux")]
fn request_transient_scope(unit: &str) -> Result<(), String> {
let owned_unit = unit.to_string();
std::thread::Builder::new()
.name("murmur-cgroup-scope".to_string())
.spawn(move || start_transient_scope(&owned_unit))
.map_err(|error| format!("could not spawn the D-Bus thread: {error}"))?
.join()
.map_err(|_| "the D-Bus thread panicked".to_string())??;
await_scope_membership(unit)
}
#[cfg(target_os = "linux")]
fn start_transient_scope(unit: &str) -> Result<(), String> {
use zbus::Variant;
let properties: Vec<(&str, Variant<'_>)> = vec![
(
"Description",
Variant::from("Murmur capsule subprocess scope"),
),
("PIDs", Variant::from(vec![std::process::id()])),
("Delegate", Variant::from(true)),
("CollectMode", Variant::from("inactive-or-failed")),
];
let aux: Vec<(&str, Vec<(&str, Variant<'_>)>)> = Vec::new();
zbus::block_on(async {
let connection = zbus::Connection::session()
.await
.map_err(|error| format!("no systemd session bus: {error}"))?;
connection
.call_method(
Some("org.freedesktop.systemd1"),
"/org/freedesktop/systemd1",
Some("org.freedesktop.systemd1.Manager"),
"StartTransientUnit",
&(unit, "fail", properties, aux),
)
.await
.map(|_| ())
.map_err(|error| format!("systemd refused to start {unit}: {error}"))
})
}
#[cfg(target_os = "linux")]
fn await_scope_membership(unit: &str) -> Result<(), String> {
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
let current = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
if let Some(path) = parse_unified_cgroup_path(¤t) {
if path.rsplit('/').next() == Some(unit) {
return Ok(());
}
}
if std::time::Instant::now() >= deadline {
return Err(format!(
"systemd accepted the request for {unit} but this process is not in it"
));
}
std::thread::sleep(Duration::from_millis(1));
}
}
#[cfg(target_os = "linux")]
fn probe_delegation() -> Result<PathBuf, CgroupError> {
let self_cgroup = std::fs::read_to_string("/proc/self/cgroup").map_err(|error| {
CgroupError::io(format!("could not read /proc/self/cgroup: {error}"), &error)
})?;
let relative = parse_unified_cgroup_path(&self_cgroup).ok_or_else(|| {
"this host has no cgroup v2 unified hierarchy (no `0::` line in /proc/self/cgroup); \
cgroup v1-only and hybrid hosts cannot be delegated a v2 scope"
.to_string()
})?;
let mount = std::fs::read_to_string("/proc/mounts")
.map(|contents| parse_cgroup2_mount(&contents))
.unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
let base = ascend_out_of_supervisor_leaves(
PathBuf::from(mount).join(relative.trim_start_matches('/')),
);
if !base.is_dir() {
return Err(format!(
"delegated cgroup directory {} does not exist",
base.display()
)
.into());
}
let controllers =
std::fs::read_to_string(base.join("cgroup.controllers")).map_err(|error| {
CgroupError::io(
format!(
"could not read {}/cgroup.controllers: {error}",
base.display()
),
&error,
)
})?;
let unavailable = missing_controllers(&controllers, REQUIRED_CONTROLLERS);
if !unavailable.is_empty() {
return Err(format!(
"cgroup controllers [{}] are not delegated to {}; the unit `mur` runs under needs \
`Delegate=yes` (or `Delegate=memory pids cpu io`)",
unavailable.join(", "),
base.display()
)
.into());
}
let probe_dir = base.join(format!("murmur-delegation-probe-{}", std::process::id()));
let _ = std::fs::remove_dir(&probe_dir);
std::fs::create_dir(&probe_dir).map_err(|error| {
CgroupError::io(
format!(
"cannot create a child cgroup under {} ({error}); the unit `mur` runs under needs \
`Delegate=yes` for memory, pids, cpu and io",
base.display()
),
&error,
)
})?;
let _ = std::fs::remove_dir(&probe_dir);
Ok(base)
}
#[cfg(target_os = "linux")]
fn enable_controllers(base: &Path) -> Result<(), CgroupError> {
let subtree_control = base.join("cgroup.subtree_control");
let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
let available = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
let mut wanted: Vec<&str> = REQUIRED_CONTROLLERS.to_vec();
if available.split_whitespace().any(|c| c == "io") {
wanted.push("io");
}
let missing = missing_controllers(¤t, &wanted);
if missing.is_empty() {
return Ok(());
}
let directive = missing
.iter()
.map(|controller| format!("+{controller}"))
.collect::<Vec<_>>()
.join(" ");
if std::fs::write(&subtree_control, format!("{directive}\n")).is_ok() {
return Ok(());
}
move_self_to_supervisor_leaf(base)?;
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let mut last_error;
loop {
let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
if missing_controllers(¤t, &wanted).is_empty() {
return Ok(());
}
match std::fs::write(&subtree_control, format!("{directive}\n")) {
Ok(()) => return Ok(()),
Err(error) => last_error = Some(error),
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(CgroupError {
errno: last_error.as_ref().and_then(std::io::Error::raw_os_error),
message: format!(
"could not enable controllers [{}] in {} ({}); the unit `mur` runs under needs \
`Delegate=yes` for memory, pids, cpu and io",
missing.join(", "),
subtree_control.display(),
last_error.map_or_else(|| "still occupied".to_string(), |error| error.to_string()),
),
})
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn ascend_out_of_supervisor_leaves(mut base: PathBuf) -> PathBuf {
while base.file_name().and_then(|name| name.to_str()) == Some(SUPERVISOR_LEAF) {
base.pop();
}
base
}
#[cfg(target_os = "linux")]
fn move_self_to_supervisor_leaf(base: &Path) -> Result<(), CgroupError> {
let leaf = base.join(SUPERVISOR_LEAF);
if let Err(error) = std::fs::create_dir(&leaf) {
if error.kind() != std::io::ErrorKind::AlreadyExists {
return Err(CgroupError::io(
format!(
"could not create {} to vacate the delegated cgroup: {error}",
leaf.display()
),
&error,
));
}
}
std::fs::write(
leaf.join("cgroup.procs"),
format!("{}\n", std::process::id()),
)
.map_err(|error| {
CgroupError::io(
format!(
"could not move this process into {} to vacate the delegated cgroup: {error}",
leaf.display()
),
&error,
)
})
}
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
fn open_write_only(path: &Path) -> std::io::Result<std::os::fd::OwnedFd> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.custom_flags(libc::O_CLOEXEC)
.open(path)
.map(std::os::fd::OwnedFd::from)
}
pub fn cgroup_delegation_available() -> bool {
#[cfg(not(target_os = "linux"))]
{
true
}
#[cfg(target_os = "linux")]
{
delegated_base().is_ok()
}
}
#[must_use]
pub fn io_max_device_available(path: &Path) -> bool {
#[cfg(not(target_os = "linux"))]
{
let _ = path;
false
}
#[cfg(target_os = "linux")]
{
io_max_device(path).is_ok()
}
}
pub fn skip_without_host_support(test_name: &str) -> bool {
if crate::network_namespace::skip_without_egress_namespace(test_name) {
return true;
}
if !cgroup_delegation_available() {
crate::runtime_err!(
"[SKIP-HOST] {test_name}: this host cannot delegate a cgroup v2 scope, so a capsule \
that can spawn subprocesses refuses to launch with E-RUN-012 before anything \
this test observes happens -- see {}",
murmur_artifact::docs_reference_url!("resource-limits/#platform-behavior")
);
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::CapabilityPolicy;
#[test]
fn io_max_status_wire_names_are_kebab_case() {
let spellings: Vec<String> = [
IoMaxStatus::Enforced,
IoMaxStatus::Unavailable,
IoMaxStatus::NotRequired,
IoMaxStatus::NotProbed,
]
.into_iter()
.map(|status| {
serde_json::to_value(status)
.unwrap()
.as_str()
.unwrap()
.to_string()
})
.collect();
assert_eq!(
spellings,
vec!["enforced", "unavailable", "not-required", "not-probed"]
);
}
#[test]
fn an_unfilled_report_claims_nothing() {
let report = IoMaxReport::default();
assert_eq!(report.status, IoMaxStatus::NotProbed);
assert_eq!(report.declared_bytes_per_sec, 0);
assert_eq!(report.reason, None);
assert_eq!(io_max_warning(&report, false), None);
}
#[test]
fn only_a_failed_write_omits_nothing_and_a_successful_one_explains_nothing() {
let enforced = serde_json::to_value(IoMaxReport::enforced(1_000)).unwrap();
assert_eq!(enforced["status"], "enforced");
assert_eq!(enforced["declared_bytes_per_sec"], 1_000);
assert!(
enforced.get("reason").is_none(),
"an applied ceiling has nothing to explain: {enforced}"
);
let unavailable =
serde_json::to_value(IoMaxReport::unavailable(1_000, "no io controller")).unwrap();
assert_eq!(unavailable["status"], "unavailable");
assert_eq!(unavailable["reason"], "no io controller");
}
#[test]
fn the_warning_fires_only_where_a_declared_ceiling_did_not_apply() {
assert_eq!(io_max_warning(&IoMaxReport::enforced(4_096), true), None);
assert_eq!(
io_max_warning(&IoMaxReport::not_required(4_096, NO_SCOPE_REASON), true),
None
);
let unavailable = IoMaxReport::unavailable(4_096, "device 0:24 is not a block device");
let (code, message) =
io_max_warning(&unavailable, true).expect("an unapplied ceiling must be reported");
assert_eq!(code, murmur_artifact::W_SEC_021);
assert!(message.contains("4096 bytes/s"), "message was: {message}");
assert!(
message.contains("device 0:24 is not a block device"),
"message was: {message}"
);
assert!(
message.contains("memory.max, pids.max and cpu.max are still enforced on the scope"),
"message was: {message}"
);
}
#[test]
fn the_warning_names_cpu_max_only_when_a_cpu_cap_is_set() {
let unavailable = IoMaxReport::unavailable(4_096, "no io controller");
let (_, message) =
io_max_warning(&unavailable, false).expect("an unapplied ceiling must be reported");
assert!(
message.contains("memory.max and pids.max are still enforced on the scope"),
"message was: {message}"
);
assert!(!message.contains("cpu.max"), "message was: {message}");
}
#[cfg(target_os = "linux")]
#[test]
fn the_device_is_resolved_from_the_nearest_existing_ancestor() {
let temp = tempfile::tempdir().unwrap();
let missing = temp
.path()
.join("workdir")
.join("does")
.join("not")
.join("exist");
assert_eq!(
nearest_existing_ancestor(&missing),
Some(temp.path()),
"an ancestor of a tempdir always exists"
);
assert_eq!(
io_max_device(&missing).ok(),
io_max_device(temp.path()).ok(),
"a path that does not exist yet must resolve to its filesystem's device"
);
assert!(!missing.exists(), "resolving a device must create nothing");
}
#[test]
fn the_covering_mount_is_the_deepest_one() {
let mountinfo = "\
25 30 0:29 / / rw,relatime shared:1 - btrfs /dev/nvme0n1p5 rw,subvol=/
26 25 0:31 / /dev/shm rw,nosuid shared:2 - tmpfs tmpfs rw,inode64
27 25 0:41 / /space rw,relatime shared:3 - btrfs /dev/nvme0n1p7 rw,subvol=/
";
let covering = |path: &str| mount_covering(mountinfo, Path::new(path)).unwrap();
assert_eq!(
covering("/space/project/workdir").source,
PathBuf::from("/dev/nvme0n1p7")
);
assert_eq!(covering("/dev/shm/project").source, PathBuf::from("tmpfs"));
assert_eq!(
covering("/home/someone").source,
PathBuf::from("/dev/nvme0n1p5"),
"a path under no nested mount belongs to the root filesystem"
);
assert_eq!(
covering("/spaceship").source,
PathBuf::from("/dev/nvme0n1p5"),
"mount points match whole path components, not string prefixes"
);
}
#[test]
fn a_mount_over_another_at_the_same_point_wins() {
let mountinfo = "\
25 30 0:29 / / rw shared:1 - ext4 /dev/sda1 rw
26 25 0:31 / /mnt rw shared:2 - ext4 /dev/sdb1 rw
27 25 0:32 / /mnt rw shared:3 - ext4 /dev/sdc1 rw
";
assert_eq!(
mount_covering(mountinfo, Path::new("/mnt/data"))
.unwrap()
.source,
PathBuf::from("/dev/sdc1")
);
}
#[test]
fn mount_point_escapes_are_decoded() {
let mountinfo = "\
25 30 0:29 / /media/a\\040disk rw shared:1 - ext4 /dev/sdb1 rw
";
let entry = mount_covering(mountinfo, Path::new("/media/a disk/project")).unwrap();
assert_eq!(entry.mount_point, PathBuf::from("/media/a disk"));
assert_eq!(entry.source, PathBuf::from("/dev/sdb1"));
assert_eq!(unescape_octal("no\\134escape"), PathBuf::from("no\\escape"));
assert_eq!(
unescape_octal("\\04"),
PathBuf::from("\\04"),
"a truncated escape is not an escape"
);
}
#[test]
fn sysfs_dev_attributes_are_read_as_major_minor() {
assert_eq!(parse_dev_line("259:0\n"), Some((259, 0)));
assert_eq!(parse_dev_line(""), None);
assert_eq!(parse_dev_line("259"), None);
}
#[cfg(target_os = "linux")]
#[test]
fn the_resolved_device_is_never_a_partition() {
let Ok((major, minor)) = io_max_device(Path::new(".")) else {
return;
};
let sysfs = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
assert!(
!sysfs.join("partition").exists(),
"io.max was given the partition {major}:{minor}, which the block layer refuses"
);
}
#[cfg(target_os = "linux")]
#[test]
fn probing_io_max_creates_nothing_that_survives_the_call() {
if skip_without_host_support("probing_io_max_creates_nothing_that_survives_the_call") {
return;
}
let temp = tempfile::tempdir().unwrap();
let probe = probe_scope(
true,
&HostResourceLimits::default(),
&temp.path().join("workdir"),
);
assert_eq!(probe.cgroup_scope, CgroupScopeReport::delegated());
let report = probe.io_max;
assert!(
matches!(
report.status,
IoMaxStatus::Enforced | IoMaxStatus::Unavailable
),
"a performed probe must report what it performed, got {report:?}"
);
let leftovers: Vec<String> = std::fs::read_dir(delegated_base().expect("a delegated base"))
.unwrap()
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
.filter(|name| name.contains("explain-scope"))
.collect();
assert!(
leftovers.is_empty(),
"the throwaway cgroup outlived the probe: {leftovers:?}"
);
}
#[test]
fn a_probe_that_is_not_required_reports_what_a_launch_writes() {
let limits = HostResourceLimits::default();
let probe = probe_scope(false, &limits, Path::new("/nonexistent/workdir"));
let prepared = prepare_scope(false, &limits, "s", Path::new("/nonexistent/workdir"))
.expect("no scope is required");
assert_eq!(probe.cgroup_scope, prepared.cgroup_scope);
assert_eq!(probe.io_max, prepared.io_max);
assert_eq!(probe.cgroup_scope.status, CgroupScopeStatus::NotRequired);
assert_eq!(
serde_json::to_value(&probe.cgroup_scope).unwrap(),
serde_json::json!({"status": "not-required", "reason": NO_CGROUP_SCOPE_REASON})
);
}
#[test]
fn the_cgroup_scope_report_serializes_each_status() {
let error = CgroupError::io(
"cannot create a child cgroup under /sys/fs/cgroup/x (Read-only file system (os \
error 30))"
.to_string(),
&std::io::Error::from_raw_os_error(libc::EROFS),
);
assert_eq!(error.to_string(), error.message);
assert_eq!(String::from(error.clone()), error.message);
let unavailable = CgroupScopeReport::unavailable(&error);
assert_eq!(
serde_json::to_value(&unavailable).unwrap(),
serde_json::json!({
"status": "unavailable",
"errno": "EROFS",
"reason": error.message,
})
);
assert_eq!(
render_cgroup_scope(&unavailable),
format!(
"cgroup scope: unavailable (EROFS) \u{2014} {}",
error.message
)
);
let delegated = CgroupScopeReport::delegated();
assert_eq!(
serde_json::to_value(&delegated).unwrap(),
serde_json::json!({"status": "delegated"})
);
assert_eq!(render_cgroup_scope(&delegated), "cgroup scope: delegated");
assert_eq!(
serde_json::to_value(CgroupScopeReport::default()).unwrap(),
serde_json::json!({"status": "not-probed"})
);
let no_errno = CgroupScopeReport::unavailable(&CgroupError::from("no v2".to_string()));
assert_eq!(no_errno.errno, None);
}
#[cfg(target_os = "linux")]
#[test]
fn a_prepared_scope_reports_delegated() {
if skip_without_host_support("a_prepared_scope_reports_delegated") {
return;
}
let temp = tempfile::tempdir().unwrap();
let prepared = prepare_scope(
true,
&HostResourceLimits::default(),
"cgroup-scope-report",
&temp.path().join("workdir"),
)
.expect("a delegated host creates the scope");
assert!(prepared.scope.is_some());
assert_eq!(prepared.cgroup_scope, CgroupScopeReport::delegated());
}
#[test]
fn scope_is_required_by_any_route_to_a_subprocess() {
let shell = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let spawn = CapabilityPolicy {
spawn_allow: vec!["child".to_string()],
..CapabilityPolicy::default()
};
let install_tool = CapabilityPolicy {
install_tool: vec!["local/tool-*".to_string()],
..CapabilityPolicy::default()
};
let install_skill = CapabilityPolicy {
install_skill: vec!["local/skill-*".to_string()],
..CapabilityPolicy::default()
};
let bare = CapabilityPolicy::default();
assert!(requires_process_bounding(&shell));
assert!(requires_process_bounding(&spawn));
assert!(
!requires_process_bounding(&install_tool),
"a pulled tool runs a program only through murmur:process, which obeys shell.allow"
);
assert!(
!requires_process_bounding(&install_skill),
"a pulled skill gains no grant and runs no subprocess"
);
for policy in [&shell, &spawn, &install_tool, &install_skill, &bare] {
assert_eq!(
requires_process_bounding(policy),
crate::runtime::has_subprocess_route_at_staging(policy),
"{policy:?}"
);
}
assert!(
!requires_process_bounding(&bare),
"a WASM-only capsule has no process tree to bound and must not be blocked"
);
}
#[test]
fn an_unconfined_session_requires_a_scope_only_for_a_declared_cgroup_field() {
use murmur_artifact::{ContainmentClass, ResourceCapabilities};
let shell = |containment_floor, declared_resources| crate::types::CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
containment_floor,
declared_resources,
..Default::default()
};
assert!(requires_process_bounding(&shell(
ContainmentClass::Advisory,
ResourceCapabilities::default()
)));
assert!(!requires_process_bounding(&shell(
ContainmentClass::Unconfined,
ResourceCapabilities::default()
)));
assert!(!requires_process_bounding(&shell(
ContainmentClass::Unconfined,
ResourceCapabilities {
max_open_files: Some(256),
workdir_max_bytes: Some(1 << 20),
..Default::default()
}
)));
for declared in [
ResourceCapabilities {
cgroup_memory_bytes: Some(1 << 30),
..Default::default()
},
ResourceCapabilities {
cgroup_pids_max: Some(64),
..Default::default()
},
ResourceCapabilities {
cgroup_cpu_percent: Some(50),
..Default::default()
},
ResourceCapabilities {
cgroup_io_bytes_per_sec: Some(1 << 20),
..Default::default()
},
] {
assert!(
requires_process_bounding(&shell(ContainmentClass::Unconfined, declared)),
"{declared:?}"
);
let bare = crate::types::CapabilityPolicy {
containment_floor: ContainmentClass::Unconfined,
declared_resources: declared,
..Default::default()
};
assert!(
!requires_process_bounding(&bare),
"no subprocess route, no scope"
);
}
let temp = tempfile::tempdir().unwrap();
let prepared = prepare_policy_scope(
&shell(
ContainmentClass::Unconfined,
ResourceCapabilities::default(),
),
"ses_unconfined",
temp.path(),
)
.expect("an unconfined session with no cgroup field is never refused");
assert!(prepared.scope.is_none());
assert_eq!(prepared.io_max.status, IoMaxStatus::NotRequired);
assert_eq!(
prepared.io_max.reason.as_deref(),
Some(UNCONFINED_NO_SCOPE_REASON)
);
}
#[test]
fn an_unconfined_scope_writes_only_the_declared_fields() {
use murmur_artifact::ResourceCapabilities;
assert!(declared_scope_limits(&ResourceCapabilities::default()).is_empty());
let files = |declared: ResourceCapabilities| -> Vec<(&str, String)> {
declared_scope_limits(&declared)
.into_iter()
.map(|limit| (limit.file, limit.value))
.collect()
};
assert_eq!(
files(ResourceCapabilities {
cgroup_pids_max: Some(64),
max_open_files: Some(256),
..Default::default()
}),
vec![("pids.max", "64".to_string())]
);
assert_eq!(
files(ResourceCapabilities {
cgroup_memory_bytes: Some(1024),
cgroup_cpu_percent: Some(50),
..Default::default()
}),
vec![
("memory.max", "1024".to_string()),
("cpu.max", format!("50000 {CPU_MAX_PERIOD_US}")),
]
);
}
#[test]
fn prepare_scope_returns_none_when_bounding_is_not_required() {
let temp = tempfile::tempdir().unwrap();
let scope = prepare_scope(
false,
&HostResourceLimits::default(),
"ses_test",
temp.path(),
)
.expect("a capsule with no subprocess capability must never be refused");
assert!(scope.scope.is_none());
assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
assert!(
scope.io_max.reason.is_some(),
"not-required must say which cause"
);
assert_eq!(io_max_warning(&scope.io_max, false), None);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn prepare_scope_is_never_an_error_off_linux() {
let temp = tempfile::tempdir().unwrap();
let scope = prepare_scope(
true,
&HostResourceLimits::default(),
"ses_test",
temp.path(),
)
.expect("a non-Linux host must not refuse to launch for a missing cgroup");
assert!(scope.scope.is_none());
assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
assert!(scope
.io_max
.reason
.as_deref()
.expect("not-required off Linux must say cgroups cannot exist here")
.contains("Linux"));
}
#[cfg(target_os = "linux")]
#[test]
#[allow(clippy::print_stdout)]
fn a_required_scope_is_created_whatever_cgroup_this_process_inherited() {
if skip_without_host_support(
"a_required_scope_is_created_whatever_cgroup_this_process_inherited",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let prepared = prepare_scope(
true,
&HostResourceLimits::default(),
"ses_selftest",
temp.path(),
)
.expect("a Linux host with a systemd user session must be able to bound a subprocess tree");
let io_max = prepared.io_max.clone();
let scope = prepared
.scope
.expect("a required scope on Linux is never `None`");
for file in ["memory.max", "pids.max"] {
let contents = std::fs::read_to_string(scope.path().join(file))
.unwrap_or_else(|error| panic!("{file} unreadable in the scope: {error}"));
assert!(!contents.trim().is_empty(), "{file} was left unset");
assert_ne!(contents.trim(), "max", "{file} carries no ceiling");
}
let cpu_max = std::fs::read_to_string(scope.path().join("cpu.max"))
.unwrap_or_else(|error| panic!("cpu.max unreadable in the scope: {error}"));
assert_eq!(cpu_max.trim(), "max 100000");
match std::fs::read_to_string(scope.path().join("memory.swap.max")) {
Ok(contents) => {
assert_eq!(
contents.trim(),
SCOPE_SWAP_MAX,
"memory.swap.max was not bounded"
);
assert_eq!(contents.trim(), "0");
println!(
"memory.swap.max in the created scope reads {}",
contents.trim()
);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
assert_eq!(probe_swap_control(), SwapControl::Absent);
println!("this kernel exposes no memory.swap.max; swap is not bounded here");
}
Err(error) => panic!("memory.swap.max unreadable in the scope: {error}"),
}
assert!(
matches!(
io_max.status,
IoMaxStatus::Enforced | IoMaxStatus::Unavailable
),
"a created scope must report a performed io.max write, got {io_max:?}"
);
assert_eq!(
io_max.declared_bytes_per_sec,
HostResourceLimits::default().cgroup_io_bytes_per_sec
);
if io_max.status == IoMaxStatus::Unavailable {
assert!(io_max.reason.is_some(), "unavailable must carry a reason");
assert!(io_max_warning(&io_max, false).is_some());
} else {
assert_eq!(io_max.reason, None);
assert_eq!(io_max_warning(&io_max, false), None);
}
}
#[cfg(target_os = "linux")]
#[test]
fn a_declared_cpu_cap_is_written_to_the_scope() {
if skip_without_host_support("a_declared_cpu_cap_is_written_to_the_scope") {
return;
}
let temp = tempfile::tempdir().unwrap();
let limits = HostResourceLimits {
cgroup_cpu_percent: Some(50),
..HostResourceLimits::default()
};
let scope = prepare_scope(true, &limits, "ses_cpucap", temp.path())
.expect(
"a Linux host with a systemd user session must be able to bound a subprocess tree",
)
.scope
.expect("a required scope on Linux is never `None`");
let cpu_max = std::fs::read_to_string(scope.path().join("cpu.max"))
.unwrap_or_else(|error| panic!("cpu.max unreadable in the scope: {error}"));
assert_eq!(cpu_max.trim(), "50000 100000");
}
#[cfg(target_os = "linux")]
#[test]
#[allow(clippy::print_stdout)]
fn killing_a_scope_without_cgroup_kill_empties_it() {
if skip_without_host_support("killing_a_scope_without_cgroup_kill_empties_it") {
return;
}
let temp = tempfile::tempdir().unwrap();
let scope = prepare_scope(
true,
&HostResourceLimits::default(),
"ses_kill_fallback",
temp.path(),
)
.expect("a delegated scope")
.scope
.expect("a required scope on Linux is never None");
let enforcement = crate::sandbox::ShellEnforcement {
cgroup_scope: Some(Arc::clone(&scope)),
..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
};
let mut command = std::process::Command::new("bash");
command
.args([
"-c",
"setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
crate::sandbox::attach_process_limits(&mut command, &enforcement);
let mut child = command.spawn().expect("bash spawns into the scope");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while scope.member_pids().len() < 3 {
assert!(
std::time::Instant::now() < deadline,
"the scope never held bash and both sleepers: {:?}",
scope.member_pids()
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
let members = scope.member_pids();
FORCE_KILL_FALLBACK.with(|flag| flag.set(true));
let killed = scope.kill_all();
FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
let emptied = scope.wait_until_empty(std::time::Duration::from_secs(2));
let status = child.wait().expect("bash is reaped");
println!(
"members before: {members:?}; fallback kill: {killed:?}; empty: {emptied}; \
cgroup.procs after: {:?}; bash: {status}",
scope.member_pids()
);
killed.expect("the fallback kill succeeds");
assert!(emptied, "the scope still holds {:?}", scope.member_pids());
assert_eq!(
std::os::unix::process::ExitStatusExt::signal(&status),
Some(libc::SIGKILL)
);
}
#[cfg(target_os = "linux")]
fn scope_with_sleepers(
session: &str,
fallback: bool,
) -> (Arc<CgroupScope>, std::process::Child) {
let temp = tempfile::tempdir().unwrap();
FORCE_KILL_FALLBACK.with(|flag| flag.set(fallback));
let scope = prepare_scope(true, &HostResourceLimits::default(), session, temp.path());
FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
let scope = scope
.expect("a delegated scope")
.scope
.expect("a required scope on Linux is never None");
let enforcement = crate::sandbox::ShellEnforcement {
cgroup_scope: Some(Arc::clone(&scope)),
..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
};
let mut command = std::process::Command::new("bash");
command
.args([
"-c",
"setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
crate::sandbox::attach_process_limits(&mut command, &enforcement);
let child = command.spawn().expect("bash spawns into the scope");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while scope.member_pids().len() < 3 {
assert!(
std::time::Instant::now() < deadline,
"the scope never held bash and both sleepers: {:?}",
scope.member_pids()
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
(scope, child)
}
#[cfg(target_os = "linux")]
fn watcher_of(scope: &CgroupScope) -> &ScopeWatcher {
scope
.watcher
.as_ref()
.expect("a scope on Linux has a watcher")
}
#[cfg(target_os = "linux")]
fn assert_a_cut_lifeline_empties_the_scope(test_name: &str, fallback: bool) {
if skip_without_host_support(test_name) {
return;
}
let (scope, mut child) = scope_with_sleepers(test_name, fallback);
let members = scope.member_pids();
watcher_of(&scope).cut_lifeline();
assert!(
scope.wait_until_empty(std::time::Duration::from_secs(5)),
"the watcher left {:?} of {members:?} in the scope",
scope.member_pids()
);
for pid in members {
assert!(
!Path::new(&format!("/proc/{pid}")).exists()
|| std::fs::read_to_string(format!("/proc/{pid}/stat"))
.is_ok_and(|stat| stat.contains(") Z ")),
"{pid} is still running"
);
}
let status = child.wait().expect("bash is reaped");
assert_eq!(
std::os::unix::process::ExitStatusExt::signal(&status),
Some(libc::SIGKILL)
);
}
#[cfg(target_os = "linux")]
#[test]
fn scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes() {
assert_a_cut_lifeline_empties_the_scope(
"scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes",
false,
);
}
#[cfg(target_os = "linux")]
#[test]
fn scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes() {
assert_a_cut_lifeline_empties_the_scope(
"scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes",
true,
);
}
#[cfg(target_os = "linux")]
#[test]
#[allow(unsafe_code)]
fn scope_watcher_holds_only_its_own_descriptors_outside_the_scope() {
if skip_without_host_support(
"scope_watcher_holds_only_its_own_descriptors_outside_the_scope",
) {
return;
}
let own = tempfile::tempfile().unwrap();
let own_target = std::fs::read_link(format!(
"/proc/self/fd/{}",
std::os::fd::AsRawFd::as_raw_fd(&own)
))
.unwrap();
let (scope, mut child) = scope_with_sleepers("ses_watcher_fds", false);
let pid = watcher_of(&scope).pid;
let fd_dir = format!("/proc/{pid}/fd");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let targets = loop {
let targets: Vec<(String, PathBuf)> = std::fs::read_dir(&fd_dir)
.unwrap()
.filter_map(Result::ok)
.filter_map(|entry| {
let target = std::fs::read_link(entry.path()).ok()?;
Some((entry.file_name().to_string_lossy().into_owned(), target))
})
.collect();
let settled = targets.iter().all(|(_, target)| {
target == Path::new("/dev/null")
|| target.to_string_lossy().starts_with("pipe:")
|| target.starts_with(scope.path())
});
if settled || std::time::Instant::now() >= deadline {
break targets;
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
let mut numbers: Vec<u32> = targets
.iter()
.map(|(name, _)| name.parse().unwrap())
.collect();
numbers.sort_unstable();
assert!(numbers.starts_with(&[0, 1, 2]), "{targets:?}");
for (name, target) in &targets {
assert_ne!(target, &own_target, "fd {name}");
if ["0", "1", "2"].contains(&name.as_str()) {
assert_eq!(target, Path::new("/dev/null"), "fd {name}");
} else {
assert!(
target.to_string_lossy().starts_with("pipe:")
|| target.starts_with(scope.path()),
"fd {name} -> {}",
target.display()
);
}
}
let pipes = targets
.iter()
.filter(|(_, target)| target.to_string_lossy().starts_with("pipe:"))
.count();
assert_eq!(pipes, 1, "the lifeline is its only pipe: {targets:?}");
assert!(!scope.member_pids().contains(&pid));
assert_eq!(
unsafe { libc::getpgid(pid) },
pid,
"a process group of its own"
);
scope.kill_all().unwrap();
child.wait().unwrap();
}
#[cfg(target_os = "linux")]
#[test]
#[allow(unsafe_code)]
fn scope_watcher_is_reaped_when_the_scope_drops() {
if skip_without_host_support("scope_watcher_is_reaped_when_the_scope_drops") {
return;
}
let (scope, mut child) = scope_with_sleepers("ses_watcher_reaped", false);
let pid = watcher_of(&scope).pid;
let path = scope.path().to_path_buf();
drop(Arc::try_unwrap(scope).expect("the test holds the only reference"));
child.wait().unwrap();
let mut status = 0;
let waited = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
assert_eq!(waited, -1);
assert_eq!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::ECHILD)
);
assert!(!path.exists(), "the scope directory is removed");
}
#[test]
fn the_scope_limits_bound_swap_at_zero() {
let declared = HostResourceLimits {
cgroup_memory_bytes: 268_435_456,
cgroup_cpu_percent: Some(50),
..HostResourceLimits::default()
};
for limits in [HostResourceLimits::default(), declared] {
let writes: Vec<(&str, String)> = scope_limits(&limits)
.into_iter()
.map(|limit| (limit.file, limit.value))
.collect();
assert!(
writes.contains(&("memory.swap.max", "0".to_string())),
"writes were {writes:?}"
);
assert!(writes.contains(&("memory.max", limits.cgroup_memory_bytes.to_string())));
assert_eq!(
&writes.iter().map(|(file, _)| *file).collect::<Vec<_>>()[..3],
["memory.max", "memory.swap.max", "pids.max"]
);
}
let absent_ok: Vec<&str> = scope_limits(&HostResourceLimits::default())
.into_iter()
.filter(|limit| limit.absent_ok)
.map(|limit| limit.file)
.collect();
assert_eq!(absent_ok, ["memory.swap.max"]);
}
#[test]
fn the_scope_limits_write_cpu_max_only_for_a_declared_cap() {
let files: Vec<&str> = scope_limits(&HostResourceLimits::resolve(None))
.into_iter()
.map(|limit| limit.file)
.collect();
assert_eq!(files, ["memory.max", "memory.swap.max", "pids.max"]);
let capped = HostResourceLimits {
cgroup_cpu_percent: Some(50),
..HostResourceLimits::default()
};
let writes = scope_limits(&capped);
assert_eq!(writes.len(), 4);
assert_eq!(writes[3].file, "cpu.max");
assert_eq!(writes[3].value, "50000 100000");
assert!(!writes[3].absent_ok);
}
#[test]
fn active_swap_areas_are_the_lines_below_the_header() {
let header = "Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n";
assert_eq!(count_swap_areas(header), 0);
assert_eq!(count_swap_areas(""), 0);
assert_eq!(
count_swap_areas(&format!(
"{header}/swapfile file\t\t33554428\t\t0\t\t-2\n"
)),
1
);
}
#[cfg(target_os = "linux")]
#[test]
fn transient_scope_unit_names_are_murmur_prefixed_and_unique() {
let first = transient_scope_unit_name();
let second = transient_scope_unit_name();
assert!(first.starts_with(&format!("murmur-{}-", std::process::id())));
assert!(first.ends_with(".scope"));
assert_ne!(first, second);
}
#[test]
fn unified_cgroup_path_is_read_from_the_zero_hierarchy_line() {
let v2_only = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope\n";
assert_eq!(
parse_unified_cgroup_path(v2_only).as_deref(),
Some("/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope")
);
let hybrid = "12:pids:/user.slice\n11:memory:/user.slice\n0::/user.slice/session.scope\n";
assert_eq!(
parse_unified_cgroup_path(hybrid).as_deref(),
Some("/user.slice/session.scope")
);
let v1_only = "12:pids:/user.slice\n11:memory:/user.slice\n";
assert_eq!(parse_unified_cgroup_path(v1_only), None);
}
#[test]
fn cgroup2_mount_falls_back_to_the_conventional_path() {
let mounts = "cgroup2 /custom/cgroup cgroup2 rw,nsdelegate 0 0\n";
assert_eq!(parse_cgroup2_mount(mounts), "/custom/cgroup");
assert_eq!(
parse_cgroup2_mount("proc /proc proc rw 0 0\n"),
"/sys/fs/cgroup"
);
}
#[test]
fn missing_controllers_reports_only_what_is_absent() {
assert_eq!(
missing_controllers("cpu memory", REQUIRED_CONTROLLERS),
vec!["pids".to_string()]
);
assert!(missing_controllers("cpu io memory pids", REQUIRED_CONTROLLERS).is_empty());
assert_eq!(
missing_controllers("", REQUIRED_CONTROLLERS),
vec!["memory".to_string(), "pids".to_string(), "cpu".to_string()]
);
}
#[test]
fn event_counters_parse_the_named_key_only() {
let memory_events = "low 0\nhigh 0\nmax 12\noom 3\noom_kill 2\n";
assert_eq!(parse_event_counter(memory_events, "oom_kill"), 2);
assert_eq!(parse_event_counter(memory_events, "max"), 12);
assert_eq!(parse_event_counter("max 7\n", "max"), 7);
assert_eq!(parse_event_counter("", "oom_kill"), 0);
assert_eq!(parse_event_counter("oom_kill\n", "oom_kill"), 0);
}
#[test]
fn attribution_uses_the_delta_across_one_call() {
let before = CgroupEventCounters {
oom_kill: 1,
pids_max: 4,
};
assert_eq!(before.attribution_since(before), None);
assert_eq!(
CgroupEventCounters {
oom_kill: 2,
pids_max: 4
}
.attribution_since(before),
Some("cgroup_memory_bytes")
);
assert_eq!(
CgroupEventCounters {
oom_kill: 1,
pids_max: 5
}
.attribution_since(before),
Some("cgroup_pids_max")
);
}
fn scope_without_kernel() -> CgroupScope {
CgroupScope {
path: PathBuf::new(),
procs_fd: None,
occupancy: Arc::default(),
#[cfg(target_os = "linux")]
watcher: None,
}
}
#[test]
fn occupancy_alone_is_exclusive() {
let scope = scope_without_kernel();
let call = scope.enter();
assert!(call.exclusive());
}
#[test]
fn occupancy_entering_beside_another_is_not_exclusive() {
let scope = scope_without_kernel();
let first = scope.enter();
let second = scope.enter();
assert!(!second.exclusive(), "someone was already inside");
drop(first);
assert!(
!second.exclusive(),
"the sibling leaving does not clean the window it shared"
);
}
#[test]
fn occupancy_someone_entering_during_the_window_makes_it_shared() {
let scope = scope_without_kernel();
let first = scope.enter();
assert!(first.exclusive());
drop(scope.enter());
assert!(
!first.exclusive(),
"a call that entered and left inside this window could have moved the counters"
);
}
#[test]
fn occupancy_drop_releases_the_scope() {
let scope = scope_without_kernel();
drop(scope.enter());
let next = scope.enter();
assert!(
next.exclusive(),
"an earlier call that has left does not share this window"
);
}
#[test]
fn occupancy_guard_outlives_its_borrow_on_another_thread() {
let scope = scope_without_kernel();
let demoted = scope.enter();
let held = std::thread::spawn(move || demoted);
let demoted = held.join().unwrap();
let foreground = scope.enter();
assert!(!foreground.exclusive());
assert!(!demoted.exclusive());
}
#[test]
fn scope_dir_name_is_sanitized_and_never_empty() {
assert!(scope_dir_name("ses_abc123").starts_with("murmur-ses_abc123-"));
assert!(scope_dir_name("../escape").starts_with("murmur-.._escape-"));
assert!(!scope_dir_name("../escape").contains('/'));
assert!(scope_dir_name("").starts_with("murmur-"));
}
#[test]
fn delegated_base_ascends_out_of_inherited_supervisor_leaves() {
let base = PathBuf::from("/sys/fs/cgroup/user.slice/app.slice/mur.scope");
assert_eq!(ascend_out_of_supervisor_leaves(base.clone()), base);
assert_eq!(
ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF)),
base
);
assert_eq!(
ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF).join(SUPERVISOR_LEAF)),
base,
"a base already nested by an earlier build must still resolve to the real root"
);
}
#[cfg(target_os = "linux")]
#[test]
fn background_resolution_agrees_with_a_direct_one() {
resolve_delegated_base_in_background();
let first = delegated_base();
let second = delegated_base();
assert_eq!(first, second);
}
#[test]
fn scope_dir_name_is_unique_per_call_for_one_id() {
let first = scope_dir_name("p");
let second = scope_dir_name("p");
assert_ne!(
first, second,
"same plan id must not reuse a scope directory"
);
}
#[test]
fn pid_is_formatted_without_allocating() {
let mut buf = [0u8; 24];
let len = format_pid(4321, &mut buf);
assert_eq!(&buf[..len], b"4321\n");
let len = format_pid(7, &mut buf);
assert_eq!(&buf[..len], b"7\n");
}
#[test]
fn device_numbers_use_the_wide_glibc_encoding() {
assert_eq!(device_major_minor(0x800), (8, 0));
assert_eq!(device_major_minor((259 << 8) | 3), (259, 3));
}
}