1use std::path::{Path, PathBuf};
74use std::sync::{
75 atomic::{AtomicU64, Ordering},
76 Arc,
77};
78#[cfg(target_os = "linux")]
79use std::time::Duration;
80
81use crate::resources::HostResourceLimits;
82
83#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
86const DEFAULT_CGROUP2_MOUNT: &str = "/sys/fs/cgroup";
87
88#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
91const CPU_MAX_PERIOD_US: u64 = 100_000;
92
93pub const SCOPE_SWAP_MAX: &str = "0";
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub(crate) struct ScopeLimit {
104 pub(crate) file: &'static str,
105 pub(crate) value: String,
106 pub(crate) absent_ok: bool,
111}
112
113#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
116pub(crate) fn scope_limits(limits: &HostResourceLimits) -> Vec<ScopeLimit> {
117 let required = |file, value| ScopeLimit {
118 file,
119 value,
120 absent_ok: false,
121 };
122 vec![
123 required("memory.max", limits.cgroup_memory_bytes.to_string()),
124 ScopeLimit {
125 file: "memory.swap.max",
126 value: SCOPE_SWAP_MAX.to_string(),
127 absent_ok: true,
128 },
129 required("pids.max", limits.cgroup_pids_max.to_string()),
130 required(
131 "cpu.max",
132 format!(
133 "{} {CPU_MAX_PERIOD_US}",
134 u64::from(limits.cgroup_cpu_percent) * (CPU_MAX_PERIOD_US / 100)
135 ),
136 ),
137 ]
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum SwapControl {
143 Exposed,
146 Absent,
149 Unknown,
152}
153
154#[must_use]
161pub fn probe_swap_control() -> SwapControl {
162 #[cfg(not(target_os = "linux"))]
163 {
164 SwapControl::Unknown
165 }
166
167 #[cfg(target_os = "linux")]
168 {
169 let mount = std::fs::read_to_string("/proc/mounts")
170 .map(|contents| parse_cgroup2_mount(&contents))
171 .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
172 let Some(own) = std::fs::read_to_string("/proc/self/cgroup")
173 .ok()
174 .and_then(|contents| parse_unified_cgroup_path(&contents))
175 else {
176 return SwapControl::Unknown;
177 };
178 let root = PathBuf::from(mount);
179 let mut dir = root.join(own.trim_start_matches('/'));
180 loop {
181 if dir.join("memory.max").exists() {
182 return if dir.join("memory.swap.max").exists() {
183 SwapControl::Exposed
184 } else {
185 SwapControl::Absent
186 };
187 }
188 if dir == root || !dir.pop() || !dir.starts_with(&root) {
189 return SwapControl::Unknown;
190 }
191 }
192 }
193}
194
195#[must_use]
197pub fn active_swap_areas() -> Option<usize> {
198 let contents = std::fs::read_to_string("/proc/swaps").ok()?;
199 Some(count_swap_areas(&contents))
200}
201
202fn count_swap_areas(contents: &str) -> usize {
204 contents
205 .lines()
206 .skip(1)
207 .filter(|line| !line.trim().is_empty())
208 .count()
209}
210
211#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
214const REQUIRED_CONTROLLERS: &[&str] = &["memory", "pids", "cpu"];
215
216#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
221const SUPERVISOR_LEAF: &str = "murmur-supervisor";
222
223#[derive(Debug)]
230pub(crate) struct CgroupScope {
231 path: PathBuf,
234 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
239 procs_fd: Option<std::os::fd::OwnedFd>,
240 occupancy: Arc<Occupancy>,
244 #[cfg(target_os = "linux")]
247 watcher: Option<ScopeWatcher>,
248}
249
250#[derive(Debug, Default)]
256struct Occupancy {
257 occupants: AtomicU64,
259 entries: AtomicU64,
262}
263
264impl Occupancy {
265 fn enter(self: &Arc<Self>) -> ScopeOccupancy {
267 let entries_before = self.entries.load(Ordering::SeqCst);
272 let others_inside = self.occupants.fetch_add(1, Ordering::SeqCst) > 0;
273 let sequence = self.entries.fetch_add(1, Ordering::SeqCst);
274 ScopeOccupancy {
275 occupancy: Arc::clone(self),
276 alone_at_entry: !others_inside && sequence == entries_before,
277 sequence,
278 }
279 }
280}
281
282#[derive(Debug)]
287pub(crate) struct ScopeOccupancy {
288 occupancy: Arc<Occupancy>,
289 alone_at_entry: bool,
290 sequence: u64,
291}
292
293impl ScopeOccupancy {
294 pub(crate) fn exclusive(&self) -> bool {
297 self.alone_at_entry && self.occupancy.entries.load(Ordering::SeqCst) == self.sequence + 1
298 }
299}
300
301impl Drop for ScopeOccupancy {
302 fn drop(&mut self) {
303 self.occupancy.occupants.fetch_sub(1, Ordering::SeqCst);
304 }
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
312pub(crate) struct CgroupEventCounters {
313 pub(crate) oom_kill: u64,
315 pub(crate) pids_max: u64,
317}
318
319impl CgroupEventCounters {
320 pub(crate) fn attribution_since(&self, before: Self) -> Option<&'static str> {
332 if self.oom_kill > before.oom_kill {
333 return Some("cgroup_memory_bytes");
334 }
335 if self.pids_max > before.pids_max {
336 return Some("cgroup_pids_max");
337 }
338 None
339 }
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
350#[serde(rename_all = "kebab-case")]
351pub enum IoMaxStatus {
352 Enforced,
355 Unavailable,
358 NotRequired,
362 #[default]
365 NotProbed,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
375pub struct IoMaxReport {
376 pub declared_bytes_per_sec: u64,
380 pub status: IoMaxStatus,
382 #[serde(skip_serializing_if = "Option::is_none")]
385 pub reason: Option<String>,
386}
387
388impl IoMaxReport {
389 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
391 fn enforced(declared_bytes_per_sec: u64) -> Self {
392 Self {
393 declared_bytes_per_sec,
394 status: IoMaxStatus::Enforced,
395 reason: None,
396 }
397 }
398
399 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
401 fn unavailable(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
402 Self {
403 declared_bytes_per_sec,
404 status: IoMaxStatus::Unavailable,
405 reason: Some(reason.into()),
406 }
407 }
408
409 #[must_use]
415 pub fn no_scope_required(declared_bytes_per_sec: u64) -> Self {
416 Self::not_required(declared_bytes_per_sec, NO_SCOPE_REASON)
417 }
418
419 fn not_required(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
423 Self {
424 declared_bytes_per_sec,
425 status: IoMaxStatus::NotRequired,
426 reason: Some(reason.into()),
427 }
428 }
429}
430
431const NO_SCOPE_REASON: &str = "this capsule declares no way to run a native subprocess, so it is \
434 given no cgroup scope and there is no process tree for an I/O ceiling to apply to";
435#[cfg_attr(target_os = "linux", allow(dead_code))]
436const NOT_LINUX_REASON: &str =
437 "cgroups are a Linux mechanism and this host has none, so no io.max ceiling can exist here";
438
439const IO_MAX_UNAVAILABLE_WARNING: &str = "the declared \
441capabilities.resources.cgroup_io_bytes_per_sec ceiling did not apply to this session's cgroup \
442scope, so this capsule's native subprocess tree has no I/O bandwidth bound; memory.max, pids.max \
443and cpu.max are still enforced on the scope";
444
445pub(crate) fn io_max_warning(report: &IoMaxReport) -> Option<(&'static str, String)> {
452 if report.status != IoMaxStatus::Unavailable {
453 return None;
454 }
455 let declared = report.declared_bytes_per_sec;
456 let reason = report.reason.as_deref().unwrap_or("no reason reported");
457 Some((
458 murmur_artifact::W_SEC_021,
459 format!("{IO_MAX_UNAVAILABLE_WARNING} (declared {declared} bytes/s): {reason}"),
460 ))
461}
462
463pub(crate) fn warn_for_unenforced_io_max(
465 workdir: &crate::workdir_fs::WorkdirRoot,
466 report: &IoMaxReport,
467) {
468 if let Some((code, message)) = io_max_warning(report) {
469 let link = murmur_artifact::security_warning_link(code);
470 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
471 crate::agent::append_bootstrap_log(
472 workdir,
473 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
474 );
475 }
476}
477
478#[derive(Debug)]
483pub(crate) struct PreparedScope {
484 pub(crate) scope: Option<Arc<CgroupScope>>,
486 pub(crate) io_max: IoMaxReport,
489}
490
491pub(crate) fn prepare_scope(
506 required: bool,
507 limits: &HostResourceLimits,
508 session_id: &str,
509 workdir: &Path,
510) -> Result<PreparedScope, String> {
511 if !required {
512 return Ok(PreparedScope {
513 scope: None,
514 io_max: IoMaxReport::no_scope_required(limits.cgroup_io_bytes_per_sec),
515 });
516 }
517 #[cfg(target_os = "linux")]
518 {
519 #[cfg(test)]
520 if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
521 return Err("forced: delegation refused by the test seam".to_string());
522 }
523 let (scope, io_max) = CgroupScope::create(limits, session_id, workdir)?;
524 Ok(PreparedScope {
525 scope: Some(Arc::new(scope)),
526 io_max,
527 })
528 }
529 #[cfg(not(target_os = "linux"))]
530 {
531 let _ = (session_id, workdir);
532 Ok(PreparedScope {
533 scope: None,
534 io_max: IoMaxReport::not_required(limits.cgroup_io_bytes_per_sec, NOT_LINUX_REASON),
535 })
536 }
537}
538
539#[must_use]
554pub fn probe_io_max(limits: &HostResourceLimits, workdir: &Path) -> IoMaxReport {
555 let declared = limits.cgroup_io_bytes_per_sec;
556 #[cfg(target_os = "linux")]
557 {
558 let base = match delegated_base() {
559 Ok(base) => base,
560 Err(reason) => {
561 return IoMaxReport::unavailable(
562 declared,
563 format!("no delegated cgroup base to write io.max into: {reason}"),
564 )
565 }
566 };
567 let path = base.join(scope_dir_name("explain-scope"));
568 if let Err(error) = std::fs::create_dir(&path) {
569 return IoMaxReport::unavailable(
570 declared,
571 format!(
572 "could not create a throwaway cgroup at {}: {error}",
573 path.display()
574 ),
575 );
576 }
577 let report = write_io_max(&path, declared, workdir);
578 let _ = std::fs::remove_dir(&path);
581 report
582 }
583 #[cfg(not(target_os = "linux"))]
584 {
585 let _ = workdir;
586 IoMaxReport::not_required(declared, NOT_LINUX_REASON)
587 }
588}
589
590pub fn requires_process_bounding(
599 policy: &crate::types::CapabilityPolicy,
600 has_native_artifact: bool,
601) -> bool {
602 crate::runtime::has_subprocess_route_at_staging(policy) || has_native_artifact
603}
604
605impl CgroupScope {
606 pub(crate) fn path(&self) -> &Path {
608 &self.path
609 }
610
611 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
619 #[allow(unsafe_code)]
620 pub(crate) fn join_current_process(&self) -> std::io::Result<()> {
621 use std::os::fd::AsRawFd;
622
623 let Some(fd) = self.procs_fd.as_ref() else {
624 return Ok(());
625 };
626
627 let pid = unsafe { libc::getpid() };
629 let mut buf = [0u8; 24];
630 let written = format_pid(pid, &mut buf);
631
632 let mut offset = 0;
633 while offset < written {
634 let rc = unsafe {
637 libc::write(
638 fd.as_raw_fd(),
639 buf[offset..written].as_ptr().cast::<libc::c_void>(),
640 written - offset,
641 )
642 };
643 if rc < 0 {
644 let error = std::io::Error::last_os_error();
645 if error.kind() == std::io::ErrorKind::Interrupted {
646 continue;
647 }
648 return Err(error);
649 }
650 offset += rc as usize;
651 }
652 Ok(())
653 }
654
655 pub(crate) fn enter(&self) -> ScopeOccupancy {
658 self.occupancy.enter()
659 }
660
661 pub(crate) fn places_subprocesses(&self) -> bool {
667 self.procs_fd.is_some()
668 }
669
670 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
675 pub(crate) fn event_counters(&self) -> CgroupEventCounters {
676 CgroupEventCounters {
677 oom_kill: read_event_counter(&self.path.join("memory.events"), "oom_kill"),
678 pids_max: read_event_counter(&self.path.join("pids.events"), "max"),
679 }
680 }
681}
682
683impl CgroupScope {
684 pub(crate) fn kill_all(&self) -> std::io::Result<()> {
695 if self.path.as_os_str().is_empty() {
696 return Ok(());
697 }
698 #[cfg(target_os = "linux")]
699 {
700 #[cfg(test)]
701 let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
702 #[cfg(not(test))]
703 let forced = false;
704 if !forced {
705 match write_existing(&self.path.join("cgroup.kill"), "1") {
706 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
707 written => return written,
708 }
709 }
710 self.kill_by_freezing()
711 }
712 #[cfg(not(target_os = "linux"))]
713 Ok(())
714 }
715
716 pub(crate) fn wait_until_empty(&self, timeout: std::time::Duration) -> bool {
721 let deadline = std::time::Instant::now() + timeout;
722 loop {
723 if self.member_pids().is_empty() {
724 return true;
725 }
726 if std::time::Instant::now() >= deadline {
727 return false;
728 }
729 std::thread::sleep(KILL_POLL_INTERVAL);
730 }
731 }
732
733 pub(crate) fn member_pids(&self) -> Vec<i32> {
736 if self.path.as_os_str().is_empty() {
737 return Vec::new();
738 }
739 std::fs::read_to_string(self.path.join("cgroup.procs"))
740 .map(|procs| {
741 procs
742 .lines()
743 .filter_map(|line| line.trim().parse().ok())
744 .collect()
745 })
746 .unwrap_or_default()
747 }
748
749 #[cfg(target_os = "linux")]
755 #[allow(unsafe_code)]
756 fn kill_by_freezing(&self) -> std::io::Result<()> {
757 let freeze = self.path.join("cgroup.freeze");
758 let deadline = std::time::Instant::now() + KILL_FALLBACK_DEADLINE;
759 loop {
760 let pids = self.member_pids();
761 if pids.is_empty() {
762 return Ok(());
763 }
764 let _ = write_existing(&freeze, "1");
766 for pid in pids {
767 unsafe { libc::kill(pid, libc::SIGKILL) };
769 }
770 let _ = write_existing(&freeze, "0");
771 if std::time::Instant::now() >= deadline {
772 return Err(std::io::Error::other(format!(
773 "processes were still in {} after {} ms of SIGKILL",
774 self.path.display(),
775 KILL_FALLBACK_DEADLINE.as_millis()
776 )));
777 }
778 std::thread::sleep(KILL_POLL_INTERVAL);
779 }
780 }
781}
782
783const KILL_FALLBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
785
786const KILL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
788
789#[cfg(target_os = "linux")]
792fn write_existing(path: &Path, value: &str) -> std::io::Result<()> {
793 use std::io::Write as _;
794 let file = std::fs::OpenOptions::new().write(true).open(path)?;
795 (&file).write_all(value.as_bytes())
796}
797
798#[cfg(all(test, target_os = "linux"))]
801thread_local! {
802 pub(crate) static FORCE_KILL_FALLBACK: std::cell::Cell<bool> =
803 const { std::cell::Cell::new(false) };
804}
805
806#[cfg(all(test, target_os = "linux"))]
809thread_local! {
810 pub(crate) static FORCE_DELEGATION_FAILURE: std::cell::Cell<bool> =
811 const { std::cell::Cell::new(false) };
812}
813
814impl Drop for CgroupScope {
815 fn drop(&mut self) {
823 if self.path.as_os_str().is_empty() {
824 return;
825 }
826 if !self.member_pids().is_empty() {
827 let _ = self.kill_all();
828 self.wait_until_empty(KILL_FALLBACK_DEADLINE);
829 }
830 self.procs_fd = None;
833 #[cfg(target_os = "linux")]
834 {
835 self.watcher = None;
836 }
837 if let Err(error) = std::fs::remove_dir(&self.path) {
838 crate::runtime_err!(
839 "[capsule-runtime] note: could not remove cgroup scope {} ({error}); \
840 it will be reclaimed once its last task exits",
841 self.path.display()
842 );
843 }
844 }
845}
846
847#[cfg(target_os = "linux")]
852#[derive(Debug)]
853struct ScopeWatcher {
854 lifeline: std::sync::Mutex<Option<std::os::fd::OwnedFd>>,
858 pid: libc::pid_t,
859}
860
861#[cfg(target_os = "linux")]
865#[derive(Clone, Copy)]
866struct WatcherKill {
867 kill: libc::c_int,
868 freeze: libc::c_int,
869 procs: libc::c_int,
870}
871
872#[cfg(target_os = "linux")]
873impl ScopeWatcher {
874 #[allow(unsafe_code)]
878 fn spawn(scope: &Path) -> std::io::Result<Self> {
879 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
880
881 #[cfg(test)]
882 let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
883 #[cfg(not(test))]
884 let forced = false;
885 let kill = if forced {
886 None
887 } else {
888 open_if_present(&scope.join("cgroup.kill"), libc::O_WRONLY)?
889 };
890 let (freeze, procs) = match kill {
891 Some(_) => (None, None),
892 None => (
893 open_if_present(&scope.join("cgroup.freeze"), libc::O_WRONLY)?,
894 Some(open_with(&scope.join("cgroup.procs"), libc::O_RDONLY)?),
895 ),
896 };
897 let devnull = open_with(Path::new("/dev/null"), libc::O_RDWR)?;
898
899 let mut ends = [0 as libc::c_int; 2];
900 if unsafe { libc::pipe2(ends.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
902 return Err(std::io::Error::last_os_error());
903 }
904 let (read_end, write_end) =
907 unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
908
909 let raw = |fd: &Option<OwnedFd>| fd.as_ref().map_or(-1, AsRawFd::as_raw_fd);
910 let plan = WatcherKill {
911 kill: raw(&kill),
912 freeze: raw(&freeze),
913 procs: raw(&procs),
914 };
915 let mut keep = [-1 as libc::c_int; 7];
919 let mut kept = 0;
920 for fd in [
921 0,
922 1,
923 2,
924 read_end.as_raw_fd(),
925 plan.kill,
926 plan.freeze,
927 plan.procs,
928 ] {
929 if fd >= 0 {
930 keep[kept] = fd;
931 kept += 1;
932 }
933 }
934 keep[..kept].sort_unstable();
935 let devnull_fd = devnull.as_raw_fd();
936 let read_fd = read_end.as_raw_fd();
937 let deadline_ns = KILL_FALLBACK_DEADLINE.as_nanos() as i64;
938
939 let pid = unsafe { libc::fork() };
945 if pid < 0 {
946 return Err(std::io::Error::last_os_error());
947 }
948 if pid == 0 {
949 unsafe { watch(devnull_fd, &keep[..kept], read_fd, plan, deadline_ns) }
951 }
952 Ok(Self {
953 lifeline: std::sync::Mutex::new(Some(write_end)),
954 pid,
955 })
956 }
957
958 #[cfg(test)]
960 fn cut_lifeline(&self) {
961 self.lifeline
962 .lock()
963 .unwrap_or_else(std::sync::PoisonError::into_inner)
964 .take();
965 }
966}
967
968#[cfg(target_os = "linux")]
969impl Drop for ScopeWatcher {
970 #[allow(unsafe_code)]
973 fn drop(&mut self) {
974 self.lifeline
975 .get_mut()
976 .unwrap_or_else(std::sync::PoisonError::into_inner)
977 .take();
978 let mut status = 0;
979 loop {
980 let waited = unsafe { libc::waitpid(self.pid, &mut status, 0) };
982 if waited >= 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
983 return;
984 }
985 }
986 }
987}
988
989#[cfg(target_os = "linux")]
991fn open_with(path: &Path, flags: libc::c_int) -> std::io::Result<std::os::fd::OwnedFd> {
992 use std::os::unix::fs::OpenOptionsExt;
993
994 std::fs::OpenOptions::new()
995 .read(flags & libc::O_ACCMODE != libc::O_WRONLY)
996 .write(flags & libc::O_ACCMODE != libc::O_RDONLY)
997 .custom_flags(libc::O_CLOEXEC)
998 .open(path)
999 .map(std::os::fd::OwnedFd::from)
1000}
1001
1002#[cfg(target_os = "linux")]
1004fn open_if_present(
1005 path: &Path,
1006 flags: libc::c_int,
1007) -> std::io::Result<Option<std::os::fd::OwnedFd>> {
1008 match open_with(path, flags) {
1009 Ok(fd) => Ok(Some(fd)),
1010 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1011 Err(error) => Err(error),
1012 }
1013}
1014
1015#[cfg(target_os = "linux")]
1024#[allow(unsafe_code)]
1025unsafe fn watch(
1026 devnull: libc::c_int,
1027 keep: &[libc::c_int],
1028 read_fd: libc::c_int,
1029 plan: WatcherKill,
1030 deadline_ns: i64,
1031) -> ! {
1032 unsafe {
1035 for target in 0..3 {
1037 libc::dup2(devnull, target);
1038 }
1039 let mut low: libc::c_uint = 0;
1040 for &fd in keep {
1041 let fd = fd as libc::c_uint;
1042 if fd > low {
1043 close_range(low, fd - 1);
1044 }
1045 low = fd + 1;
1046 }
1047 close_range(low, libc::c_uint::MAX);
1048
1049 libc::setpgid(0, 0);
1050 let mut ignore: libc::sigaction = std::mem::zeroed();
1051 ignore.sa_sigaction = libc::SIG_IGN;
1052 for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGPIPE] {
1053 libc::sigaction(signal, &ignore, std::ptr::null_mut());
1054 }
1055
1056 let mut byte = [0u8; 1];
1057 loop {
1058 let read = libc::read(read_fd, byte.as_mut_ptr().cast(), 1);
1059 if read == 0 {
1060 break;
1061 }
1062 if read < 0 && *libc::__errno_location() != libc::EINTR {
1063 break;
1064 }
1065 }
1066
1067 kill_scope(plan, deadline_ns);
1068 libc::_exit(0)
1069 }
1070}
1071
1072#[cfg(target_os = "linux")]
1079#[allow(unsafe_code)]
1080unsafe fn close_range(low: libc::c_uint, high: libc::c_uint) {
1081 unsafe {
1083 if libc::syscall(libc::SYS_close_range, low, high, 0 as libc::c_uint) == 0 {
1084 return;
1085 }
1086 let mut limit: libc::rlimit = std::mem::zeroed();
1087 let ceiling = if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) == 0 {
1088 limit.rlim_cur.min(libc::c_uint::MAX as libc::rlim_t) as libc::c_uint
1089 } else {
1090 1 << 20
1091 };
1092 let mut fd = low;
1093 while fd <= high && fd < ceiling {
1094 libc::close(fd as libc::c_int);
1095 fd += 1;
1096 }
1097 }
1098}
1099
1100#[cfg(target_os = "linux")]
1107#[allow(unsafe_code)]
1108unsafe fn kill_scope(plan: WatcherKill, deadline_ns: i64) {
1109 unsafe {
1111 if plan.kill >= 0 {
1112 write_control(plan.kill, b"1");
1113 return;
1114 }
1115 let start = monotonic_ns();
1116 loop {
1117 if plan.freeze >= 0 {
1118 write_control(plan.freeze, b"1");
1119 }
1120 let signalled = signal_listed(plan.procs);
1121 if plan.freeze >= 0 {
1122 write_control(plan.freeze, b"0");
1123 }
1124 if signalled == 0 || monotonic_ns() - start >= deadline_ns {
1125 return;
1126 }
1127 let pause = libc::timespec {
1128 tv_sec: 0,
1129 tv_nsec: KILL_POLL_INTERVAL.as_nanos() as libc::c_long,
1130 };
1131 libc::nanosleep(&pause, std::ptr::null_mut());
1132 }
1133 }
1134}
1135
1136#[cfg(target_os = "linux")]
1142#[allow(unsafe_code)]
1143unsafe fn write_control(fd: libc::c_int, value: &[u8]) {
1144 unsafe {
1146 libc::lseek(fd, 0, libc::SEEK_SET);
1147 libc::write(fd, value.as_ptr().cast(), value.len());
1148 }
1149}
1150
1151#[cfg(target_os = "linux")]
1158#[allow(unsafe_code)]
1159unsafe fn signal_listed(procs: libc::c_int) -> usize {
1160 unsafe {
1162 if procs < 0 || libc::lseek(procs, 0, libc::SEEK_SET) < 0 {
1163 return 0;
1164 }
1165 let mut buf = [0u8; 512];
1166 let mut pid: libc::pid_t = 0;
1167 let mut digits = false;
1168 let mut signalled = 0;
1169 loop {
1170 let read = libc::read(procs, buf.as_mut_ptr().cast(), buf.len());
1171 if read < 0 && *libc::__errno_location() == libc::EINTR {
1172 continue;
1173 }
1174 if read <= 0 {
1175 break;
1176 }
1177 for &byte in &buf[..read as usize] {
1178 if byte.is_ascii_digit() {
1179 pid = pid
1180 .saturating_mul(10)
1181 .saturating_add(libc::pid_t::from(byte - b'0'));
1182 digits = true;
1183 } else {
1184 if digits && pid > 0 {
1185 libc::kill(pid, libc::SIGKILL);
1186 signalled += 1;
1187 }
1188 pid = 0;
1189 digits = false;
1190 }
1191 }
1192 }
1193 if digits && pid > 0 {
1194 libc::kill(pid, libc::SIGKILL);
1195 signalled += 1;
1196 }
1197 signalled
1198 }
1199}
1200
1201#[cfg(target_os = "linux")]
1207#[allow(unsafe_code)]
1208unsafe fn monotonic_ns() -> i64 {
1209 unsafe {
1211 let mut now: libc::timespec = std::mem::zeroed();
1212 libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now);
1213 now.tv_sec as i64 * 1_000_000_000 + now.tv_nsec as i64
1214 }
1215}
1216
1217#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1222fn format_pid(pid: i32, buf: &mut [u8; 24]) -> usize {
1223 let mut digits = [0u8; 20];
1224 let mut count = 0;
1225 let mut value = pid.unsigned_abs() as u64;
1226 if value == 0 {
1227 digits[0] = b'0';
1228 count = 1;
1229 }
1230 while value > 0 {
1231 digits[count] = b'0' + (value % 10) as u8;
1232 value /= 10;
1233 count += 1;
1234 }
1235
1236 let mut written = 0;
1237 for index in (0..count).rev() {
1238 buf[written] = digits[index];
1239 written += 1;
1240 }
1241 buf[written] = b'\n';
1242 written + 1
1243}
1244
1245#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1250fn read_event_counter(path: &Path, key: &str) -> u64 {
1251 let Ok(contents) = std::fs::read_to_string(path) else {
1252 return 0;
1253 };
1254 parse_event_counter(&contents, key)
1255}
1256
1257#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1259fn parse_event_counter(contents: &str, key: &str) -> u64 {
1260 for line in contents.lines() {
1261 let mut parts = line.split_whitespace();
1262 if parts.next() == Some(key) {
1263 return parts
1264 .next()
1265 .and_then(|value| value.parse().ok())
1266 .unwrap_or(0);
1267 }
1268 }
1269 0
1270}
1271
1272#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1284fn scope_dir_name(session_id: &str) -> String {
1285 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1286 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1287
1288 let sanitized: String = session_id
1289 .chars()
1290 .map(|c| {
1291 if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
1292 c
1293 } else {
1294 '_'
1295 }
1296 })
1297 .collect();
1298 let pid = std::process::id();
1299 if sanitized.is_empty() {
1300 format!("murmur-{pid}-{serial}")
1301 } else {
1302 format!("murmur-{sanitized}-{pid}-{serial}")
1303 }
1304}
1305
1306#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1312fn parse_unified_cgroup_path(contents: &str) -> Option<String> {
1313 for line in contents.lines() {
1314 let mut parts = line.splitn(3, ':');
1315 let (Some("0"), Some(""), Some(path)) = (parts.next(), parts.next(), parts.next()) else {
1316 continue;
1317 };
1318 if path.starts_with('/') {
1319 return Some(path.to_string());
1320 }
1321 }
1322 None
1323}
1324
1325#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1328fn parse_cgroup2_mount(contents: &str) -> String {
1329 for line in contents.lines() {
1330 let mut parts = line.split_whitespace();
1331 let (Some(_source), Some(target), Some(fstype)) =
1332 (parts.next(), parts.next(), parts.next())
1333 else {
1334 continue;
1335 };
1336 if fstype == "cgroup2" {
1337 return target.to_string();
1338 }
1339 }
1340 DEFAULT_CGROUP2_MOUNT.to_string()
1341}
1342
1343#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1345fn missing_controllers(subtree_control: &str, wanted: &[&str]) -> Vec<String> {
1346 let enabled: Vec<&str> = subtree_control.split_whitespace().collect();
1347 wanted
1348 .iter()
1349 .filter(|controller| !enabled.contains(*controller))
1350 .map(|controller| (*controller).to_string())
1351 .collect()
1352}
1353
1354#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1357fn device_major_minor(dev: u64) -> (u64, u64) {
1358 let major = ((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff);
1359 let minor = (dev & 0xff) | ((dev >> 12) & !0xff);
1360 (major, minor)
1361}
1362
1363#[cfg(target_os = "linux")]
1364impl CgroupScope {
1365 fn create(
1372 limits: &HostResourceLimits,
1373 session_id: &str,
1374 workdir: &Path,
1375 ) -> Result<(Self, IoMaxReport), String> {
1376 let base = delegated_base()?;
1377
1378 let path = base.join(scope_dir_name(session_id));
1379 if let Err(error) = std::fs::create_dir(&path) {
1380 if error.kind() != std::io::ErrorKind::AlreadyExists {
1381 return Err(format!(
1382 "could not create cgroup scope at {}: {error}",
1383 path.display()
1384 ));
1385 }
1386 }
1387
1388 let scope = Self {
1389 path,
1390 procs_fd: None,
1391 occupancy: Arc::default(),
1392 watcher: None,
1393 };
1394
1395 for limit in scope_limits(limits) {
1396 scope.write_limit(&limit)?;
1397 }
1398 let io_max = scope.apply_io_max(limits.cgroup_io_bytes_per_sec, workdir);
1399
1400 let procs_fd = open_write_only(&scope.path.join("cgroup.procs")).map_err(|error| {
1403 format!(
1404 "could not open {}/cgroup.procs for subprocess placement: {error}",
1405 scope.path.display()
1406 )
1407 })?;
1408
1409 let watcher = ScopeWatcher::spawn(&scope.path).map_err(|error| {
1410 format!(
1411 "could not start the watcher that kills {} if the runtime dies: {error}",
1412 scope.path.display()
1413 )
1414 })?;
1415
1416 Ok((
1417 Self {
1418 path: scope.take_path(),
1419 procs_fd: Some(procs_fd),
1420 occupancy: Arc::default(),
1421 watcher: Some(watcher),
1422 },
1423 io_max,
1424 ))
1425 }
1426
1427 fn take_path(mut self) -> PathBuf {
1430 let path = std::mem::take(&mut self.path);
1431 path
1433 }
1434
1435 fn write_limit(&self, limit: &ScopeLimit) -> Result<(), String> {
1438 let path = self.path.join(limit.file);
1439 let value = &limit.value;
1440 match write_existing(&path, &format!("{value}\n")) {
1441 Err(error) if limit.absent_ok && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1442 written => written
1443 .map_err(|error| format!("could not write {value} to {}: {error}", path.display())),
1444 }
1445 }
1446
1447 fn apply_io_max(&self, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1459 write_io_max(&self.path, bytes_per_sec, workdir)
1460 }
1461}
1462
1463#[cfg(target_os = "linux")]
1473fn write_io_max(scope_path: &Path, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1474 let (major, minor) = match io_max_device(workdir) {
1475 Ok(device) => device,
1476 Err(reason) => return IoMaxReport::unavailable(bytes_per_sec, reason),
1477 };
1478 let value = format!("{major}:{minor} rbps={bytes_per_sec} wbps={bytes_per_sec}");
1479 match std::fs::write(scope_path.join("io.max"), format!("{value}\n")) {
1480 Ok(()) => IoMaxReport::enforced(bytes_per_sec),
1481 Err(error) => IoMaxReport::unavailable(
1487 bytes_per_sec,
1488 format!("writing `{value}` to the scope's io.max: {error}"),
1489 ),
1490 }
1491}
1492
1493#[cfg(target_os = "linux")]
1511fn io_max_device(path: &Path) -> Result<(u64, u64), String> {
1512 use std::os::linux::fs::MetadataExt;
1513
1514 let Some(existing) = nearest_existing_ancestor(path) else {
1515 return Err(format!(
1516 "could not stat {} or any ancestor of it to find its backing device",
1517 path.display()
1518 ));
1519 };
1520 let metadata = existing.metadata().map_err(|error| {
1521 format!(
1522 "could not stat {} to find its backing device: {error}",
1523 existing.display()
1524 )
1525 })?;
1526 let (major, minor) = device_major_minor(metadata.st_dev());
1527 let (major, minor) = if major == 0 {
1528 mount_source_device(existing)?
1529 } else {
1530 (major, minor)
1531 };
1532 Ok(whole_disk_device(major, minor))
1533}
1534
1535#[cfg(target_os = "linux")]
1541fn mount_source_device(path: &Path) -> Result<(u64, u64), String> {
1542 use std::os::unix::fs::{FileTypeExt, MetadataExt};
1543
1544 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1545 let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(|error| {
1546 format!(
1547 "reading /proc/self/mountinfo to find the device behind {}: {error}",
1548 resolved.display()
1549 )
1550 })?;
1551 let Some(mount) = mount_covering(&mountinfo, &resolved) else {
1552 return Err(format!(
1553 "no mount in /proc/self/mountinfo covers {}, so its backing device is unknown",
1554 resolved.display()
1555 ));
1556 };
1557 match std::fs::metadata(&mount.source) {
1558 Ok(metadata) if metadata.file_type().is_block_device() => {
1559 Ok(device_major_minor(metadata.rdev()))
1560 }
1561 _ => Err(format!(
1562 "the filesystem mounted at {} is backed by `{}`, which is not a block device, so no \
1563 io.max ceiling can name one",
1564 mount.mount_point.display(),
1565 mount.source.display()
1566 )),
1567 }
1568}
1569
1570#[cfg(target_os = "linux")]
1578fn whole_disk_device(major: u64, minor: u64) -> (u64, u64) {
1579 let device = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
1580 if !device.join("partition").exists() {
1581 return (major, minor);
1582 }
1583 std::fs::read_to_string(device.join("../dev"))
1584 .ok()
1585 .and_then(|contents| parse_dev_line(&contents))
1586 .unwrap_or((major, minor))
1587}
1588
1589#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1591fn parse_dev_line(contents: &str) -> Option<(u64, u64)> {
1592 let (major, minor) = contents.trim().split_once(':')?;
1593 Some((major.parse().ok()?, minor.parse().ok()?))
1594}
1595
1596#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1599#[derive(Debug, PartialEq, Eq)]
1600struct MountEntry {
1601 mount_point: PathBuf,
1603 source: PathBuf,
1606}
1607
1608#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1613fn mount_covering(mountinfo: &str, path: &Path) -> Option<MountEntry> {
1614 let mut best: Option<(usize, MountEntry)> = None;
1615 for line in mountinfo.lines() {
1616 let Some(entry) = parse_mount_entry(line) else {
1617 continue;
1618 };
1619 if !path.starts_with(&entry.mount_point) {
1620 continue;
1621 }
1622 let depth = entry.mount_point.components().count();
1623 if best.as_ref().is_none_or(|(best, _)| depth >= *best) {
1624 best = Some((depth, entry));
1625 }
1626 }
1627 best.map(|(_, entry)| entry)
1628}
1629
1630#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1635fn parse_mount_entry(line: &str) -> Option<MountEntry> {
1636 let (mounted, backing) = line.split_once(" - ")?;
1637 let mount_point = mounted.split_whitespace().nth(4)?;
1638 let source = backing.split_whitespace().nth(1)?;
1639 Some(MountEntry {
1640 mount_point: unescape_octal(mount_point),
1641 source: unescape_octal(source),
1642 })
1643}
1644
1645#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1648fn unescape_octal(field: &str) -> PathBuf {
1649 use std::os::unix::ffi::OsStringExt;
1650
1651 let bytes = field.as_bytes();
1652 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
1653 let mut index = 0;
1654 while index < bytes.len() {
1655 let escape = bytes[index] == b'\\'
1656 && index + 3 < bytes.len()
1657 && bytes[index + 1..index + 4]
1658 .iter()
1659 .all(|byte| (b'0'..=b'7').contains(byte));
1660 if escape {
1661 let digits = &field[index + 1..index + 4];
1662 out.push(u8::from_str_radix(digits, 8).expect("three octal digits"));
1663 index += 4;
1664 } else {
1665 out.push(bytes[index]);
1666 index += 1;
1667 }
1668 }
1669 PathBuf::from(std::ffi::OsString::from_vec(out))
1670}
1671
1672#[cfg(target_os = "linux")]
1678fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
1679 let mut candidate = Some(path);
1680 while let Some(current) = candidate {
1681 if current.exists() {
1682 return Some(current);
1683 }
1684 candidate = current.parent();
1685 }
1686 None
1687}
1688
1689#[cfg(target_os = "linux")]
1703fn delegated_base() -> Result<PathBuf, String> {
1704 static BASE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
1705
1706 let mut cached = BASE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1709 if let Some(base) = cached.as_ref() {
1710 return Ok(base.clone());
1711 }
1712
1713 move_self_into_own_systemd_scope();
1716
1717 let base = probe_delegation()?;
1718 enable_controllers(&base)?;
1719 *cached = Some(base.clone());
1720 Ok(base)
1721}
1722
1723#[cfg(target_os = "linux")]
1733fn move_self_into_own_systemd_scope() {
1734 static ATTEMPTED: std::sync::Once = std::sync::Once::new();
1735 ATTEMPTED.call_once(|| {
1736 let _ = request_transient_scope(&transient_scope_unit_name());
1737 });
1738}
1739
1740#[cfg(target_os = "linux")]
1747fn transient_scope_unit_name() -> String {
1748 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1749 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1750 format!("murmur-{}-{serial}.scope", std::process::id())
1751}
1752
1753#[cfg(target_os = "linux")]
1756fn request_transient_scope(unit: &str) -> Result<(), String> {
1757 let owned_unit = unit.to_string();
1760 std::thread::Builder::new()
1761 .name("murmur-cgroup-scope".to_string())
1762 .spawn(move || start_transient_scope(&owned_unit))
1763 .map_err(|error| format!("could not spawn the D-Bus thread: {error}"))?
1764 .join()
1765 .map_err(|_| "the D-Bus thread panicked".to_string())??;
1766
1767 await_scope_membership(unit)
1768}
1769
1770#[cfg(target_os = "linux")]
1786fn start_transient_scope(unit: &str) -> Result<(), String> {
1787 use zbus::Variant;
1788
1789 let properties: Vec<(&str, Variant<'_>)> = vec![
1790 (
1791 "Description",
1792 Variant::from("Murmur capsule subprocess scope"),
1793 ),
1794 ("PIDs", Variant::from(vec![std::process::id()])),
1795 ("Delegate", Variant::from(true)),
1796 ("CollectMode", Variant::from("inactive-or-failed")),
1797 ];
1798 let aux: Vec<(&str, Vec<(&str, Variant<'_>)>)> = Vec::new();
1800
1801 zbus::block_on(async {
1802 let connection = zbus::Connection::session()
1803 .await
1804 .map_err(|error| format!("no systemd session bus: {error}"))?;
1805 connection
1806 .call_method(
1807 Some("org.freedesktop.systemd1"),
1808 "/org/freedesktop/systemd1",
1809 Some("org.freedesktop.systemd1.Manager"),
1810 "StartTransientUnit",
1811 &(unit, "fail", properties, aux),
1814 )
1815 .await
1816 .map(|_| ())
1817 .map_err(|error| format!("systemd refused to start {unit}: {error}"))
1818 })
1819}
1820
1821#[cfg(target_os = "linux")]
1835fn await_scope_membership(unit: &str) -> Result<(), String> {
1836 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1837 loop {
1838 let current = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
1839 if let Some(path) = parse_unified_cgroup_path(¤t) {
1840 if path.rsplit('/').next() == Some(unit) {
1841 return Ok(());
1842 }
1843 }
1844 if std::time::Instant::now() >= deadline {
1845 return Err(format!(
1846 "systemd accepted the request for {unit} but this process is not in it"
1847 ));
1848 }
1849 std::thread::sleep(Duration::from_millis(1));
1850 }
1851}
1852
1853#[cfg(target_os = "linux")]
1861fn probe_delegation() -> Result<PathBuf, String> {
1862 let self_cgroup = std::fs::read_to_string("/proc/self/cgroup")
1863 .map_err(|error| format!("could not read /proc/self/cgroup: {error}"))?;
1864 let relative = parse_unified_cgroup_path(&self_cgroup).ok_or_else(|| {
1865 "this host has no cgroup v2 unified hierarchy (no `0::` line in /proc/self/cgroup); \
1866 cgroup v1-only and hybrid hosts cannot be delegated a v2 scope"
1867 .to_string()
1868 })?;
1869
1870 let mount = std::fs::read_to_string("/proc/mounts")
1871 .map(|contents| parse_cgroup2_mount(&contents))
1872 .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
1873
1874 let base = ascend_out_of_supervisor_leaves(
1875 PathBuf::from(mount).join(relative.trim_start_matches('/')),
1876 );
1877 if !base.is_dir() {
1878 return Err(format!(
1879 "delegated cgroup directory {} does not exist",
1880 base.display()
1881 ));
1882 }
1883
1884 let controllers =
1885 std::fs::read_to_string(base.join("cgroup.controllers")).map_err(|error| {
1886 format!(
1887 "could not read {}/cgroup.controllers: {error}",
1888 base.display()
1889 )
1890 })?;
1891 let unavailable = missing_controllers(&controllers, REQUIRED_CONTROLLERS);
1892 if !unavailable.is_empty() {
1893 return Err(format!(
1894 "cgroup controllers [{}] are not delegated to {}; the unit `mur` runs under needs \
1895 `Delegate=yes` (or `Delegate=memory pids cpu io`)",
1896 unavailable.join(", "),
1897 base.display()
1898 ));
1899 }
1900
1901 let probe_dir = base.join(format!("murmur-delegation-probe-{}", std::process::id()));
1905 let _ = std::fs::remove_dir(&probe_dir);
1906 std::fs::create_dir(&probe_dir).map_err(|error| {
1907 format!(
1908 "cannot create a child cgroup under {} ({error}); the unit `mur` runs under needs \
1909 `Delegate=yes` for memory, pids, cpu and io",
1910 base.display()
1911 )
1912 })?;
1913 let _ = std::fs::remove_dir(&probe_dir);
1914
1915 Ok(base)
1916}
1917
1918#[cfg(target_os = "linux")]
1925fn enable_controllers(base: &Path) -> Result<(), String> {
1926 let subtree_control = base.join("cgroup.subtree_control");
1927 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
1928
1929 let available = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
1932 let mut wanted: Vec<&str> = REQUIRED_CONTROLLERS.to_vec();
1933 if available.split_whitespace().any(|c| c == "io") {
1934 wanted.push("io");
1935 }
1936
1937 let missing = missing_controllers(¤t, &wanted);
1938 if missing.is_empty() {
1939 return Ok(());
1940 }
1941 let directive = missing
1942 .iter()
1943 .map(|controller| format!("+{controller}"))
1944 .collect::<Vec<_>>()
1945 .join(" ");
1946
1947 if std::fs::write(&subtree_control, format!("{directive}\n")).is_ok() {
1948 return Ok(());
1949 }
1950
1951 move_self_to_supervisor_leaf(base)?;
1952
1953 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1964 let mut last_error;
1967 loop {
1968 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
1969 if missing_controllers(¤t, &wanted).is_empty() {
1970 return Ok(());
1971 }
1972 match std::fs::write(&subtree_control, format!("{directive}\n")) {
1973 Ok(()) => return Ok(()),
1974 Err(error) => last_error = Some(error),
1975 }
1976 if std::time::Instant::now() >= deadline {
1977 break;
1978 }
1979 std::thread::sleep(Duration::from_millis(20));
1980 }
1981
1982 Err(format!(
1983 "could not enable controllers [{}] in {} ({}); the unit `mur` runs under needs \
1984 `Delegate=yes` for memory, pids, cpu and io",
1985 missing.join(", "),
1986 subtree_control.display(),
1987 last_error.map_or_else(|| "still occupied".to_string(), |error| error.to_string()),
1988 ))
1989}
1990
1991#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2002fn ascend_out_of_supervisor_leaves(mut base: PathBuf) -> PathBuf {
2003 while base.file_name().and_then(|name| name.to_str()) == Some(SUPERVISOR_LEAF) {
2004 base.pop();
2005 }
2006 base
2007}
2008
2009#[cfg(target_os = "linux")]
2011fn move_self_to_supervisor_leaf(base: &Path) -> Result<(), String> {
2012 let leaf = base.join(SUPERVISOR_LEAF);
2013 if let Err(error) = std::fs::create_dir(&leaf) {
2014 if error.kind() != std::io::ErrorKind::AlreadyExists {
2015 return Err(format!(
2016 "could not create {} to vacate the delegated cgroup: {error}",
2017 leaf.display()
2018 ));
2019 }
2020 }
2021 std::fs::write(
2022 leaf.join("cgroup.procs"),
2023 format!("{}\n", std::process::id()),
2024 )
2025 .map_err(|error| {
2026 format!(
2027 "could not move this process into {} to vacate the delegated cgroup: {error}",
2028 leaf.display()
2029 )
2030 })
2031}
2032
2033#[cfg(target_os = "linux")]
2039#[allow(unsafe_code)]
2040fn open_write_only(path: &Path) -> std::io::Result<std::os::fd::OwnedFd> {
2041 use std::os::unix::fs::OpenOptionsExt;
2042
2043 std::fs::OpenOptions::new()
2044 .write(true)
2045 .custom_flags(libc::O_CLOEXEC)
2046 .open(path)
2047 .map(std::os::fd::OwnedFd::from)
2048}
2049
2050pub fn cgroup_delegation_available() -> bool {
2065 #[cfg(not(target_os = "linux"))]
2066 {
2067 true
2068 }
2069
2070 #[cfg(target_os = "linux")]
2071 {
2072 delegated_base().is_ok()
2078 }
2079}
2080
2081#[must_use]
2090pub fn io_max_device_available(path: &Path) -> bool {
2091 #[cfg(not(target_os = "linux"))]
2092 {
2093 let _ = path;
2094 false
2095 }
2096
2097 #[cfg(target_os = "linux")]
2098 {
2099 io_max_device(path).is_ok()
2100 }
2101}
2102
2103pub fn skip_without_host_support(test_name: &str) -> bool {
2111 if crate::network_namespace::skip_without_egress_namespace(test_name) {
2112 return true;
2113 }
2114 if !cgroup_delegation_available() {
2115 crate::runtime_err!(
2116 "[SKIP-HOST] {test_name}: this host cannot delegate a cgroup v2 scope, so a capsule \
2117 that can spawn native subprocesses refuses to launch with E-RUN-012 before anything \
2118 this test observes happens -- see {}",
2119 murmur_artifact::docs_reference_url!("resource-limits/#platform-behavior")
2120 );
2121 return true;
2122 }
2123 false
2124}
2125
2126#[cfg(test)]
2127mod tests {
2128 use super::*;
2129 use crate::types::CapabilityPolicy;
2130
2131 #[test]
2135 fn io_max_status_wire_names_are_kebab_case() {
2136 let spellings: Vec<String> = [
2137 IoMaxStatus::Enforced,
2138 IoMaxStatus::Unavailable,
2139 IoMaxStatus::NotRequired,
2140 IoMaxStatus::NotProbed,
2141 ]
2142 .into_iter()
2143 .map(|status| {
2144 serde_json::to_value(status)
2145 .unwrap()
2146 .as_str()
2147 .unwrap()
2148 .to_string()
2149 })
2150 .collect();
2151 assert_eq!(
2152 spellings,
2153 vec!["enforced", "unavailable", "not-required", "not-probed"]
2154 );
2155 }
2156
2157 #[test]
2160 fn an_unfilled_report_claims_nothing() {
2161 let report = IoMaxReport::default();
2162 assert_eq!(report.status, IoMaxStatus::NotProbed);
2163 assert_eq!(report.declared_bytes_per_sec, 0);
2164 assert_eq!(report.reason, None);
2165 assert_eq!(io_max_warning(&report), None);
2166 }
2167
2168 #[test]
2170 fn only_a_failed_write_omits_nothing_and_a_successful_one_explains_nothing() {
2171 let enforced = serde_json::to_value(IoMaxReport::enforced(1_000)).unwrap();
2172 assert_eq!(enforced["status"], "enforced");
2173 assert_eq!(enforced["declared_bytes_per_sec"], 1_000);
2174 assert!(
2175 enforced.get("reason").is_none(),
2176 "an applied ceiling has nothing to explain: {enforced}"
2177 );
2178
2179 let unavailable =
2180 serde_json::to_value(IoMaxReport::unavailable(1_000, "no io controller")).unwrap();
2181 assert_eq!(unavailable["status"], "unavailable");
2182 assert_eq!(unavailable["reason"], "no io controller");
2183 }
2184
2185 #[test]
2189 fn the_warning_fires_only_where_a_declared_ceiling_did_not_apply() {
2190 assert_eq!(io_max_warning(&IoMaxReport::enforced(4_096)), None);
2191 assert_eq!(
2192 io_max_warning(&IoMaxReport::not_required(4_096, NO_SCOPE_REASON)),
2193 None
2194 );
2195
2196 let (code, message) = io_max_warning(&IoMaxReport::unavailable(
2197 4_096,
2198 "device 0:24 is not a block device",
2199 ))
2200 .expect("an unapplied ceiling must be reported");
2201 assert_eq!(code, murmur_artifact::W_SEC_021);
2202 assert!(message.contains("4096 bytes/s"), "message was: {message}");
2203 assert!(
2204 message.contains("device 0:24 is not a block device"),
2205 "message was: {message}"
2206 );
2207 assert!(message.contains("memory.max"), "message was: {message}");
2208 }
2209
2210 #[cfg(target_os = "linux")]
2213 #[test]
2214 fn the_device_is_resolved_from_the_nearest_existing_ancestor() {
2215 let temp = tempfile::tempdir().unwrap();
2216 let missing = temp
2217 .path()
2218 .join("workdir")
2219 .join("does")
2220 .join("not")
2221 .join("exist");
2222 assert_eq!(
2223 nearest_existing_ancestor(&missing),
2224 Some(temp.path()),
2225 "an ancestor of a tempdir always exists"
2226 );
2227 assert_eq!(
2228 io_max_device(&missing).ok(),
2229 io_max_device(temp.path()).ok(),
2230 "a path that does not exist yet must resolve to its filesystem's device"
2231 );
2232 assert!(!missing.exists(), "resolving a device must create nothing");
2233 }
2234
2235 #[test]
2238 fn the_covering_mount_is_the_deepest_one() {
2239 let mountinfo = "\
224025 30 0:29 / / rw,relatime shared:1 - btrfs /dev/nvme0n1p5 rw,subvol=/
224126 25 0:31 / /dev/shm rw,nosuid shared:2 - tmpfs tmpfs rw,inode64
224227 25 0:41 / /space rw,relatime shared:3 - btrfs /dev/nvme0n1p7 rw,subvol=/
2243";
2244 let covering = |path: &str| mount_covering(mountinfo, Path::new(path)).unwrap();
2245
2246 assert_eq!(
2247 covering("/space/project/workdir").source,
2248 PathBuf::from("/dev/nvme0n1p7")
2249 );
2250 assert_eq!(covering("/dev/shm/project").source, PathBuf::from("tmpfs"));
2251 assert_eq!(
2252 covering("/home/someone").source,
2253 PathBuf::from("/dev/nvme0n1p5"),
2254 "a path under no nested mount belongs to the root filesystem"
2255 );
2256 assert_eq!(
2257 covering("/spaceship").source,
2258 PathBuf::from("/dev/nvme0n1p5"),
2259 "mount points match whole path components, not string prefixes"
2260 );
2261 }
2262
2263 #[test]
2266 fn a_mount_over_another_at_the_same_point_wins() {
2267 let mountinfo = "\
226825 30 0:29 / / rw shared:1 - ext4 /dev/sda1 rw
226926 25 0:31 / /mnt rw shared:2 - ext4 /dev/sdb1 rw
227027 25 0:32 / /mnt rw shared:3 - ext4 /dev/sdc1 rw
2271";
2272 assert_eq!(
2273 mount_covering(mountinfo, Path::new("/mnt/data"))
2274 .unwrap()
2275 .source,
2276 PathBuf::from("/dev/sdc1")
2277 );
2278 }
2279
2280 #[test]
2283 fn mount_point_escapes_are_decoded() {
2284 let mountinfo = "\
228525 30 0:29 / /media/a\\040disk rw shared:1 - ext4 /dev/sdb1 rw
2286";
2287 let entry = mount_covering(mountinfo, Path::new("/media/a disk/project")).unwrap();
2288 assert_eq!(entry.mount_point, PathBuf::from("/media/a disk"));
2289 assert_eq!(entry.source, PathBuf::from("/dev/sdb1"));
2290 assert_eq!(unescape_octal("no\\134escape"), PathBuf::from("no\\escape"));
2291 assert_eq!(
2292 unescape_octal("\\04"),
2293 PathBuf::from("\\04"),
2294 "a truncated escape is not an escape"
2295 );
2296 }
2297
2298 #[test]
2300 fn sysfs_dev_attributes_are_read_as_major_minor() {
2301 assert_eq!(parse_dev_line("259:0\n"), Some((259, 0)));
2302 assert_eq!(parse_dev_line(""), None);
2303 assert_eq!(parse_dev_line("259"), None);
2304 }
2305
2306 #[cfg(target_os = "linux")]
2310 #[test]
2311 fn the_resolved_device_is_never_a_partition() {
2312 let Ok((major, minor)) = io_max_device(Path::new(".")) else {
2313 return;
2316 };
2317 let sysfs = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
2318 assert!(
2319 !sysfs.join("partition").exists(),
2320 "io.max was given the partition {major}:{minor}, which the block layer refuses"
2321 );
2322 }
2323
2324 #[cfg(target_os = "linux")]
2327 #[test]
2328 fn probing_io_max_creates_nothing_that_survives_the_call() {
2329 if skip_without_host_support("probing_io_max_creates_nothing_that_survives_the_call") {
2330 return;
2331 }
2332 let temp = tempfile::tempdir().unwrap();
2333 let report = probe_io_max(&HostResourceLimits::default(), &temp.path().join("workdir"));
2334 assert!(
2335 matches!(
2336 report.status,
2337 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
2338 ),
2339 "a performed probe must report what it performed, got {report:?}"
2340 );
2341
2342 let leftovers: Vec<String> = std::fs::read_dir(delegated_base().expect("a delegated base"))
2346 .unwrap()
2347 .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
2348 .filter(|name| name.contains("explain-scope"))
2349 .collect();
2350 assert!(
2351 leftovers.is_empty(),
2352 "the throwaway cgroup outlived the probe: {leftovers:?}"
2353 );
2354 }
2355
2356 #[test]
2357 fn scope_is_required_by_any_route_to_a_native_subprocess() {
2358 let shell = CapabilityPolicy {
2359 shell_allow: vec!["bash".to_string()],
2360 ..CapabilityPolicy::default()
2361 };
2362 let spawn = CapabilityPolicy {
2363 spawn_allow: vec!["child".to_string()],
2364 ..CapabilityPolicy::default()
2365 };
2366 let install_tool = CapabilityPolicy {
2367 install_tool: vec!["local/tool-*".to_string()],
2368 ..CapabilityPolicy::default()
2369 };
2370 let install_skill = CapabilityPolicy {
2371 install_skill: vec!["local/skill-*".to_string()],
2372 ..CapabilityPolicy::default()
2373 };
2374 let bare = CapabilityPolicy::default();
2375
2376 assert!(requires_process_bounding(&shell, false));
2377 assert!(requires_process_bounding(&spawn, false));
2378 assert!(
2379 requires_process_bounding(&install_tool, false),
2380 "a tool the capsule may pull can be native"
2381 );
2382 assert!(
2383 !requires_process_bounding(&install_skill, false),
2384 "a pulled skill gains no grant and runs no subprocess"
2385 );
2386 for policy in [&shell, &spawn, &install_tool, &install_skill, &bare] {
2387 assert_eq!(
2388 requires_process_bounding(policy, false),
2389 crate::runtime::has_subprocess_route_at_staging(policy),
2390 "{policy:?}"
2391 );
2392 }
2393 assert!(
2394 requires_process_bounding(&bare, true),
2395 "a native artifact alone is enough"
2396 );
2397 assert!(
2398 !requires_process_bounding(&bare, false),
2399 "a WASM-only capsule has no process tree to bound and must not be blocked"
2400 );
2401 }
2402
2403 #[test]
2407 fn prepare_scope_returns_none_when_bounding_is_not_required() {
2408 let temp = tempfile::tempdir().unwrap();
2409 let scope = prepare_scope(
2410 false,
2411 &HostResourceLimits::default(),
2412 "ses_test",
2413 temp.path(),
2414 )
2415 .expect("a capsule with no subprocess capability must never be refused");
2416 assert!(scope.scope.is_none());
2417 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
2419 assert!(
2420 scope.io_max.reason.is_some(),
2421 "not-required must say which cause"
2422 );
2423 assert_eq!(io_max_warning(&scope.io_max), None);
2424 }
2425
2426 #[cfg(not(target_os = "linux"))]
2429 #[test]
2430 fn prepare_scope_is_never_an_error_off_linux() {
2431 let temp = tempfile::tempdir().unwrap();
2432 let scope = prepare_scope(
2433 true,
2434 &HostResourceLimits::default(),
2435 "ses_test",
2436 temp.path(),
2437 )
2438 .expect("a non-Linux host must not refuse to launch for a missing cgroup");
2439 assert!(scope.scope.is_none());
2440 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
2441 assert!(scope
2442 .io_max
2443 .reason
2444 .as_deref()
2445 .expect("not-required off Linux must say cgroups cannot exist here")
2446 .contains("Linux"));
2447 }
2448
2449 #[cfg(target_os = "linux")]
2454 #[test]
2455 #[allow(clippy::print_stdout)]
2456 fn a_required_scope_is_created_whatever_cgroup_this_process_inherited() {
2457 if skip_without_host_support(
2458 "a_required_scope_is_created_whatever_cgroup_this_process_inherited",
2459 ) {
2460 return;
2461 }
2462 let temp = tempfile::tempdir().unwrap();
2463 let prepared = prepare_scope(
2464 true,
2465 &HostResourceLimits::default(),
2466 "ses_selftest",
2467 temp.path(),
2468 )
2469 .expect("a Linux host with a systemd user session must be able to bound a subprocess tree");
2470 let io_max = prepared.io_max.clone();
2471 let scope = prepared
2472 .scope
2473 .expect("a required scope on Linux is never `None`");
2474
2475 for file in ["memory.max", "pids.max", "cpu.max"] {
2476 let contents = std::fs::read_to_string(scope.path().join(file))
2477 .unwrap_or_else(|error| panic!("{file} unreadable in the scope: {error}"));
2478 assert!(!contents.trim().is_empty(), "{file} was left unset");
2479 assert_ne!(contents.trim(), "max", "{file} carries no ceiling");
2480 }
2481 match std::fs::read_to_string(scope.path().join("memory.swap.max")) {
2482 Ok(contents) => {
2483 assert_eq!(
2484 contents.trim(),
2485 SCOPE_SWAP_MAX,
2486 "memory.swap.max was not bounded"
2487 );
2488 assert_eq!(contents.trim(), "0");
2489 println!(
2490 "memory.swap.max in the created scope reads {}",
2491 contents.trim()
2492 );
2493 }
2494 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2495 assert_eq!(probe_swap_control(), SwapControl::Absent);
2496 println!("this kernel exposes no memory.swap.max; swap is not bounded here");
2497 }
2498 Err(error) => panic!("memory.swap.max unreadable in the scope: {error}"),
2499 }
2500
2501 assert!(
2504 matches!(
2505 io_max.status,
2506 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
2507 ),
2508 "a created scope must report a performed io.max write, got {io_max:?}"
2509 );
2510 assert_eq!(
2511 io_max.declared_bytes_per_sec,
2512 HostResourceLimits::default().cgroup_io_bytes_per_sec
2513 );
2514 if io_max.status == IoMaxStatus::Unavailable {
2515 assert!(io_max.reason.is_some(), "unavailable must carry a reason");
2516 assert!(io_max_warning(&io_max).is_some());
2517 } else {
2518 assert_eq!(io_max.reason, None);
2519 assert_eq!(io_max_warning(&io_max), None);
2520 }
2521 }
2522
2523 #[cfg(target_os = "linux")]
2527 #[test]
2528 #[allow(clippy::print_stdout)]
2529 fn killing_a_scope_without_cgroup_kill_empties_it() {
2530 if skip_without_host_support("killing_a_scope_without_cgroup_kill_empties_it") {
2531 return;
2532 }
2533 let temp = tempfile::tempdir().unwrap();
2534 let scope = prepare_scope(
2535 true,
2536 &HostResourceLimits::default(),
2537 "ses_kill_fallback",
2538 temp.path(),
2539 )
2540 .expect("a delegated scope")
2541 .scope
2542 .expect("a required scope on Linux is never None");
2543 let enforcement = crate::sandbox::ShellEnforcement {
2544 cgroup_scope: Some(Arc::clone(&scope)),
2545 ..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
2546 };
2547 let mut command = std::process::Command::new("bash");
2548 command
2549 .args([
2550 "-c",
2551 "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
2552 ])
2553 .stdout(std::process::Stdio::null())
2554 .stderr(std::process::Stdio::null());
2555 crate::sandbox::attach_process_limits(&mut command, &enforcement);
2556 let mut child = command.spawn().expect("bash spawns into the scope");
2557
2558 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2559 while scope.member_pids().len() < 3 {
2560 assert!(
2561 std::time::Instant::now() < deadline,
2562 "the scope never held bash and both sleepers: {:?}",
2563 scope.member_pids()
2564 );
2565 std::thread::sleep(std::time::Duration::from_millis(20));
2566 }
2567 let members = scope.member_pids();
2568
2569 FORCE_KILL_FALLBACK.with(|flag| flag.set(true));
2570 let killed = scope.kill_all();
2571 FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
2572 let emptied = scope.wait_until_empty(std::time::Duration::from_secs(2));
2573 let status = child.wait().expect("bash is reaped");
2574 println!(
2575 "members before: {members:?}; fallback kill: {killed:?}; empty: {emptied}; \
2576 cgroup.procs after: {:?}; bash: {status}",
2577 scope.member_pids()
2578 );
2579 killed.expect("the fallback kill succeeds");
2580 assert!(emptied, "the scope still holds {:?}", scope.member_pids());
2581 assert_eq!(
2582 std::os::unix::process::ExitStatusExt::signal(&status),
2583 Some(libc::SIGKILL)
2584 );
2585 }
2586
2587 #[cfg(target_os = "linux")]
2590 fn scope_with_sleepers(
2591 session: &str,
2592 fallback: bool,
2593 ) -> (Arc<CgroupScope>, std::process::Child) {
2594 let temp = tempfile::tempdir().unwrap();
2595 FORCE_KILL_FALLBACK.with(|flag| flag.set(fallback));
2596 let scope = prepare_scope(true, &HostResourceLimits::default(), session, temp.path());
2597 FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
2598 let scope = scope
2599 .expect("a delegated scope")
2600 .scope
2601 .expect("a required scope on Linux is never None");
2602 let enforcement = crate::sandbox::ShellEnforcement {
2603 cgroup_scope: Some(Arc::clone(&scope)),
2604 ..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
2605 };
2606 let mut command = std::process::Command::new("bash");
2607 command
2608 .args([
2609 "-c",
2610 "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
2611 ])
2612 .stdout(std::process::Stdio::null())
2613 .stderr(std::process::Stdio::null());
2614 crate::sandbox::attach_process_limits(&mut command, &enforcement);
2615 let child = command.spawn().expect("bash spawns into the scope");
2616 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2617 while scope.member_pids().len() < 3 {
2618 assert!(
2619 std::time::Instant::now() < deadline,
2620 "the scope never held bash and both sleepers: {:?}",
2621 scope.member_pids()
2622 );
2623 std::thread::sleep(std::time::Duration::from_millis(20));
2624 }
2625 (scope, child)
2626 }
2627
2628 #[cfg(target_os = "linux")]
2629 fn watcher_of(scope: &CgroupScope) -> &ScopeWatcher {
2630 scope
2631 .watcher
2632 .as_ref()
2633 .expect("a scope on Linux has a watcher")
2634 }
2635
2636 #[cfg(target_os = "linux")]
2639 fn assert_a_cut_lifeline_empties_the_scope(test_name: &str, fallback: bool) {
2640 if skip_without_host_support(test_name) {
2641 return;
2642 }
2643 let (scope, mut child) = scope_with_sleepers(test_name, fallback);
2644 let members = scope.member_pids();
2645 watcher_of(&scope).cut_lifeline();
2646 assert!(
2647 scope.wait_until_empty(std::time::Duration::from_secs(5)),
2648 "the watcher left {:?} of {members:?} in the scope",
2649 scope.member_pids()
2650 );
2651 for pid in members {
2652 assert!(
2653 !Path::new(&format!("/proc/{pid}")).exists()
2654 || std::fs::read_to_string(format!("/proc/{pid}/stat"))
2655 .is_ok_and(|stat| stat.contains(") Z ")),
2656 "{pid} is still running"
2657 );
2658 }
2659 let status = child.wait().expect("bash is reaped");
2660 assert_eq!(
2661 std::os::unix::process::ExitStatusExt::signal(&status),
2662 Some(libc::SIGKILL)
2663 );
2664 }
2665
2666 #[cfg(target_os = "linux")]
2667 #[test]
2668 fn scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes() {
2669 assert_a_cut_lifeline_empties_the_scope(
2670 "scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes",
2671 false,
2672 );
2673 }
2674
2675 #[cfg(target_os = "linux")]
2676 #[test]
2677 fn scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes() {
2678 assert_a_cut_lifeline_empties_the_scope(
2679 "scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes",
2680 true,
2681 );
2682 }
2683
2684 #[cfg(target_os = "linux")]
2688 #[test]
2689 #[allow(unsafe_code)]
2690 fn scope_watcher_holds_only_its_own_descriptors_outside_the_scope() {
2691 if skip_without_host_support(
2692 "scope_watcher_holds_only_its_own_descriptors_outside_the_scope",
2693 ) {
2694 return;
2695 }
2696 let own = tempfile::tempfile().unwrap();
2698 let own_target = std::fs::read_link(format!(
2699 "/proc/self/fd/{}",
2700 std::os::fd::AsRawFd::as_raw_fd(&own)
2701 ))
2702 .unwrap();
2703 let (scope, mut child) = scope_with_sleepers("ses_watcher_fds", false);
2704 let pid = watcher_of(&scope).pid;
2705
2706 let fd_dir = format!("/proc/{pid}/fd");
2707 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2708 let targets = loop {
2709 let targets: Vec<(String, PathBuf)> = std::fs::read_dir(&fd_dir)
2710 .unwrap()
2711 .filter_map(Result::ok)
2712 .filter_map(|entry| {
2713 let target = std::fs::read_link(entry.path()).ok()?;
2714 Some((entry.file_name().to_string_lossy().into_owned(), target))
2715 })
2716 .collect();
2717 let settled = targets.iter().all(|(_, target)| {
2718 target == Path::new("/dev/null")
2719 || target.to_string_lossy().starts_with("pipe:")
2720 || target.starts_with(scope.path())
2721 });
2722 if settled || std::time::Instant::now() >= deadline {
2723 break targets;
2724 }
2725 std::thread::sleep(std::time::Duration::from_millis(20));
2726 };
2727 let mut numbers: Vec<u32> = targets
2728 .iter()
2729 .map(|(name, _)| name.parse().unwrap())
2730 .collect();
2731 numbers.sort_unstable();
2732 assert!(numbers.starts_with(&[0, 1, 2]), "{targets:?}");
2733 for (name, target) in &targets {
2734 assert_ne!(target, &own_target, "fd {name}");
2735 if ["0", "1", "2"].contains(&name.as_str()) {
2736 assert_eq!(target, Path::new("/dev/null"), "fd {name}");
2737 } else {
2738 assert!(
2739 target.to_string_lossy().starts_with("pipe:")
2740 || target.starts_with(scope.path()),
2741 "fd {name} -> {}",
2742 target.display()
2743 );
2744 }
2745 }
2746 let pipes = targets
2747 .iter()
2748 .filter(|(_, target)| target.to_string_lossy().starts_with("pipe:"))
2749 .count();
2750 assert_eq!(pipes, 1, "the lifeline is its only pipe: {targets:?}");
2751
2752 assert!(!scope.member_pids().contains(&pid));
2753 assert_eq!(
2755 unsafe { libc::getpgid(pid) },
2756 pid,
2757 "a process group of its own"
2758 );
2759
2760 scope.kill_all().unwrap();
2761 child.wait().unwrap();
2762 }
2763
2764 #[cfg(target_os = "linux")]
2766 #[test]
2767 #[allow(unsafe_code)]
2768 fn scope_watcher_is_reaped_when_the_scope_drops() {
2769 if skip_without_host_support("scope_watcher_is_reaped_when_the_scope_drops") {
2770 return;
2771 }
2772 let (scope, mut child) = scope_with_sleepers("ses_watcher_reaped", false);
2773 let pid = watcher_of(&scope).pid;
2774 let path = scope.path().to_path_buf();
2775 drop(Arc::try_unwrap(scope).expect("the test holds the only reference"));
2776 child.wait().unwrap();
2777 let mut status = 0;
2778 let waited = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
2780 assert_eq!(waited, -1);
2781 assert_eq!(
2782 std::io::Error::last_os_error().raw_os_error(),
2783 Some(libc::ECHILD)
2784 );
2785 assert!(!path.exists(), "the scope directory is removed");
2786 }
2787
2788 #[test]
2791 fn the_scope_limits_bound_swap_at_zero() {
2792 let declared = HostResourceLimits {
2793 cgroup_memory_bytes: 268_435_456,
2794 ..HostResourceLimits::default()
2795 };
2796 for limits in [HostResourceLimits::default(), declared] {
2797 let writes: Vec<(&str, String)> = scope_limits(&limits)
2798 .into_iter()
2799 .map(|limit| (limit.file, limit.value))
2800 .collect();
2801 assert!(
2802 writes.contains(&("memory.swap.max", "0".to_string())),
2803 "writes were {writes:?}"
2804 );
2805 assert!(writes.contains(&("memory.max", limits.cgroup_memory_bytes.to_string())));
2806 assert_eq!(
2807 writes.iter().map(|(file, _)| *file).collect::<Vec<_>>(),
2808 ["memory.max", "memory.swap.max", "pids.max", "cpu.max"]
2809 );
2810 }
2811 let absent_ok: Vec<&str> = scope_limits(&HostResourceLimits::default())
2812 .into_iter()
2813 .filter(|limit| limit.absent_ok)
2814 .map(|limit| limit.file)
2815 .collect();
2816 assert_eq!(absent_ok, ["memory.swap.max"]);
2817 }
2818
2819 #[test]
2820 fn active_swap_areas_are_the_lines_below_the_header() {
2821 let header = "Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n";
2822 assert_eq!(count_swap_areas(header), 0);
2823 assert_eq!(count_swap_areas(""), 0);
2824 assert_eq!(
2825 count_swap_areas(&format!(
2826 "{header}/swapfile file\t\t33554428\t\t0\t\t-2\n"
2827 )),
2828 1
2829 );
2830 }
2831
2832 #[cfg(target_os = "linux")]
2835 #[test]
2836 fn transient_scope_unit_names_are_murmur_prefixed_and_unique() {
2837 let first = transient_scope_unit_name();
2838 let second = transient_scope_unit_name();
2839 assert!(first.starts_with(&format!("murmur-{}-", std::process::id())));
2840 assert!(first.ends_with(".scope"));
2841 assert_ne!(first, second);
2842 }
2843
2844 #[test]
2845 fn unified_cgroup_path_is_read_from_the_zero_hierarchy_line() {
2846 let v2_only = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope\n";
2847 assert_eq!(
2848 parse_unified_cgroup_path(v2_only).as_deref(),
2849 Some("/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope")
2850 );
2851
2852 let hybrid = "12:pids:/user.slice\n11:memory:/user.slice\n0::/user.slice/session.scope\n";
2853 assert_eq!(
2854 parse_unified_cgroup_path(hybrid).as_deref(),
2855 Some("/user.slice/session.scope")
2856 );
2857
2858 let v1_only = "12:pids:/user.slice\n11:memory:/user.slice\n";
2859 assert_eq!(parse_unified_cgroup_path(v1_only), None);
2860 }
2861
2862 #[test]
2863 fn cgroup2_mount_falls_back_to_the_conventional_path() {
2864 let mounts = "cgroup2 /custom/cgroup cgroup2 rw,nsdelegate 0 0\n";
2865 assert_eq!(parse_cgroup2_mount(mounts), "/custom/cgroup");
2866 assert_eq!(
2867 parse_cgroup2_mount("proc /proc proc rw 0 0\n"),
2868 "/sys/fs/cgroup"
2869 );
2870 }
2871
2872 #[test]
2873 fn missing_controllers_reports_only_what_is_absent() {
2874 assert_eq!(
2875 missing_controllers("cpu memory", REQUIRED_CONTROLLERS),
2876 vec!["pids".to_string()]
2877 );
2878 assert!(missing_controllers("cpu io memory pids", REQUIRED_CONTROLLERS).is_empty());
2879 assert_eq!(
2880 missing_controllers("", REQUIRED_CONTROLLERS),
2881 vec!["memory".to_string(), "pids".to_string(), "cpu".to_string()]
2882 );
2883 }
2884
2885 #[test]
2886 fn event_counters_parse_the_named_key_only() {
2887 let memory_events = "low 0\nhigh 0\nmax 12\noom 3\noom_kill 2\n";
2888 assert_eq!(parse_event_counter(memory_events, "oom_kill"), 2);
2889 assert_eq!(parse_event_counter(memory_events, "max"), 12);
2890 assert_eq!(parse_event_counter("max 7\n", "max"), 7);
2891 assert_eq!(parse_event_counter("", "oom_kill"), 0);
2892 assert_eq!(parse_event_counter("oom_kill\n", "oom_kill"), 0);
2893 }
2894
2895 #[test]
2898 fn attribution_uses_the_delta_across_one_call() {
2899 let before = CgroupEventCounters {
2900 oom_kill: 1,
2901 pids_max: 4,
2902 };
2903 assert_eq!(before.attribution_since(before), None);
2904 assert_eq!(
2905 CgroupEventCounters {
2906 oom_kill: 2,
2907 pids_max: 4
2908 }
2909 .attribution_since(before),
2910 Some("cgroup_memory_bytes")
2911 );
2912 assert_eq!(
2913 CgroupEventCounters {
2914 oom_kill: 1,
2915 pids_max: 5
2916 }
2917 .attribution_since(before),
2918 Some("cgroup_pids_max")
2919 );
2920 }
2921
2922 fn scope_without_kernel() -> CgroupScope {
2925 CgroupScope {
2926 path: PathBuf::new(),
2927 procs_fd: None,
2928 occupancy: Arc::default(),
2929 #[cfg(target_os = "linux")]
2930 watcher: None,
2931 }
2932 }
2933
2934 #[test]
2935 fn occupancy_alone_is_exclusive() {
2936 let scope = scope_without_kernel();
2937 let call = scope.enter();
2938 assert!(call.exclusive());
2939 }
2940
2941 #[test]
2942 fn occupancy_entering_beside_another_is_not_exclusive() {
2943 let scope = scope_without_kernel();
2944 let first = scope.enter();
2945 let second = scope.enter();
2946 assert!(!second.exclusive(), "someone was already inside");
2947 drop(first);
2948 assert!(
2949 !second.exclusive(),
2950 "the sibling leaving does not clean the window it shared"
2951 );
2952 }
2953
2954 #[test]
2955 fn occupancy_someone_entering_during_the_window_makes_it_shared() {
2956 let scope = scope_without_kernel();
2957 let first = scope.enter();
2958 assert!(first.exclusive());
2959 drop(scope.enter());
2960 assert!(
2961 !first.exclusive(),
2962 "a call that entered and left inside this window could have moved the counters"
2963 );
2964 }
2965
2966 #[test]
2967 fn occupancy_drop_releases_the_scope() {
2968 let scope = scope_without_kernel();
2969 drop(scope.enter());
2970 let next = scope.enter();
2971 assert!(
2972 next.exclusive(),
2973 "an earlier call that has left does not share this window"
2974 );
2975 }
2976
2977 #[test]
2980 fn occupancy_guard_outlives_its_borrow_on_another_thread() {
2981 let scope = scope_without_kernel();
2982 let demoted = scope.enter();
2983 let held = std::thread::spawn(move || demoted);
2984 let demoted = held.join().unwrap();
2985 let foreground = scope.enter();
2986 assert!(!foreground.exclusive());
2987 assert!(!demoted.exclusive());
2988 }
2989
2990 #[test]
2991 fn scope_dir_name_is_sanitized_and_never_empty() {
2992 assert!(scope_dir_name("ses_abc123").starts_with("murmur-ses_abc123-"));
2993 assert!(scope_dir_name("../escape").starts_with("murmur-.._escape-"));
2994 assert!(!scope_dir_name("../escape").contains('/'));
2995 assert!(scope_dir_name("").starts_with("murmur-"));
2996 }
2997
2998 #[test]
3002 fn delegated_base_ascends_out_of_inherited_supervisor_leaves() {
3003 let base = PathBuf::from("/sys/fs/cgroup/user.slice/app.slice/mur.scope");
3004
3005 assert_eq!(ascend_out_of_supervisor_leaves(base.clone()), base);
3006 assert_eq!(
3007 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF)),
3008 base
3009 );
3010 assert_eq!(
3011 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF).join(SUPERVISOR_LEAF)),
3012 base,
3013 "a base already nested by an earlier build must still resolve to the real root"
3014 );
3015 }
3016
3017 #[test]
3021 fn scope_dir_name_is_unique_per_call_for_one_id() {
3022 let first = scope_dir_name("p");
3023 let second = scope_dir_name("p");
3024 assert_ne!(
3025 first, second,
3026 "same plan id must not reuse a scope directory"
3027 );
3028 }
3029
3030 #[test]
3031 fn pid_is_formatted_without_allocating() {
3032 let mut buf = [0u8; 24];
3033 let len = format_pid(4321, &mut buf);
3034 assert_eq!(&buf[..len], b"4321\n");
3035
3036 let len = format_pid(7, &mut buf);
3037 assert_eq!(&buf[..len], b"7\n");
3038 }
3039
3040 #[test]
3041 fn device_numbers_use_the_wide_glibc_encoding() {
3042 assert_eq!(device_major_minor(0x800), (8, 0));
3044 assert_eq!(device_major_minor((259 << 8) | 3), (259, 3));
3046 }
3047}