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 let (scope, io_max) = CgroupScope::create(limits, session_id, workdir)?;
520 Ok(PreparedScope {
521 scope: Some(Arc::new(scope)),
522 io_max,
523 })
524 }
525 #[cfg(not(target_os = "linux"))]
526 {
527 let _ = (session_id, workdir);
528 Ok(PreparedScope {
529 scope: None,
530 io_max: IoMaxReport::not_required(limits.cgroup_io_bytes_per_sec, NOT_LINUX_REASON),
531 })
532 }
533}
534
535#[must_use]
550pub fn probe_io_max(limits: &HostResourceLimits, workdir: &Path) -> IoMaxReport {
551 let declared = limits.cgroup_io_bytes_per_sec;
552 #[cfg(target_os = "linux")]
553 {
554 let base = match delegated_base() {
555 Ok(base) => base,
556 Err(reason) => {
557 return IoMaxReport::unavailable(
558 declared,
559 format!("no delegated cgroup base to write io.max into: {reason}"),
560 )
561 }
562 };
563 let path = base.join(scope_dir_name("explain-scope"));
564 if let Err(error) = std::fs::create_dir(&path) {
565 return IoMaxReport::unavailable(
566 declared,
567 format!(
568 "could not create a throwaway cgroup at {}: {error}",
569 path.display()
570 ),
571 );
572 }
573 let report = write_io_max(&path, declared, workdir);
574 let _ = std::fs::remove_dir(&path);
577 report
578 }
579 #[cfg(not(target_os = "linux"))]
580 {
581 let _ = workdir;
582 IoMaxReport::not_required(declared, NOT_LINUX_REASON)
583 }
584}
585
586pub fn requires_process_bounding(
592 policy: &crate::types::CapabilityPolicy,
593 has_native_artifact: bool,
594) -> bool {
595 !policy.shell_allow.is_empty() || !policy.spawn_allow.is_empty() || has_native_artifact
596}
597
598impl CgroupScope {
599 pub(crate) fn path(&self) -> &Path {
601 &self.path
602 }
603
604 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
612 #[allow(unsafe_code)]
613 pub(crate) fn join_current_process(&self) -> std::io::Result<()> {
614 use std::os::fd::AsRawFd;
615
616 let Some(fd) = self.procs_fd.as_ref() else {
617 return Ok(());
618 };
619
620 let pid = unsafe { libc::getpid() };
622 let mut buf = [0u8; 24];
623 let written = format_pid(pid, &mut buf);
624
625 let mut offset = 0;
626 while offset < written {
627 let rc = unsafe {
630 libc::write(
631 fd.as_raw_fd(),
632 buf[offset..written].as_ptr().cast::<libc::c_void>(),
633 written - offset,
634 )
635 };
636 if rc < 0 {
637 let error = std::io::Error::last_os_error();
638 if error.kind() == std::io::ErrorKind::Interrupted {
639 continue;
640 }
641 return Err(error);
642 }
643 offset += rc as usize;
644 }
645 Ok(())
646 }
647
648 pub(crate) fn enter(&self) -> ScopeOccupancy {
651 self.occupancy.enter()
652 }
653
654 pub(crate) fn places_subprocesses(&self) -> bool {
660 self.procs_fd.is_some()
661 }
662
663 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
668 pub(crate) fn event_counters(&self) -> CgroupEventCounters {
669 CgroupEventCounters {
670 oom_kill: read_event_counter(&self.path.join("memory.events"), "oom_kill"),
671 pids_max: read_event_counter(&self.path.join("pids.events"), "max"),
672 }
673 }
674}
675
676impl CgroupScope {
677 pub(crate) fn kill_all(&self) -> std::io::Result<()> {
688 if self.path.as_os_str().is_empty() {
689 return Ok(());
690 }
691 #[cfg(target_os = "linux")]
692 {
693 #[cfg(test)]
694 let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
695 #[cfg(not(test))]
696 let forced = false;
697 if !forced {
698 match write_existing(&self.path.join("cgroup.kill"), "1") {
699 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
700 written => return written,
701 }
702 }
703 self.kill_by_freezing()
704 }
705 #[cfg(not(target_os = "linux"))]
706 Ok(())
707 }
708
709 pub(crate) fn wait_until_empty(&self, timeout: std::time::Duration) -> bool {
714 let deadline = std::time::Instant::now() + timeout;
715 loop {
716 if self.member_pids().is_empty() {
717 return true;
718 }
719 if std::time::Instant::now() >= deadline {
720 return false;
721 }
722 std::thread::sleep(KILL_POLL_INTERVAL);
723 }
724 }
725
726 pub(crate) fn member_pids(&self) -> Vec<i32> {
729 if self.path.as_os_str().is_empty() {
730 return Vec::new();
731 }
732 std::fs::read_to_string(self.path.join("cgroup.procs"))
733 .map(|procs| {
734 procs
735 .lines()
736 .filter_map(|line| line.trim().parse().ok())
737 .collect()
738 })
739 .unwrap_or_default()
740 }
741
742 #[cfg(target_os = "linux")]
748 #[allow(unsafe_code)]
749 fn kill_by_freezing(&self) -> std::io::Result<()> {
750 let freeze = self.path.join("cgroup.freeze");
751 let deadline = std::time::Instant::now() + KILL_FALLBACK_DEADLINE;
752 loop {
753 let pids = self.member_pids();
754 if pids.is_empty() {
755 return Ok(());
756 }
757 let _ = write_existing(&freeze, "1");
759 for pid in pids {
760 unsafe { libc::kill(pid, libc::SIGKILL) };
762 }
763 let _ = write_existing(&freeze, "0");
764 if std::time::Instant::now() >= deadline {
765 return Err(std::io::Error::other(format!(
766 "processes were still in {} after {} ms of SIGKILL",
767 self.path.display(),
768 KILL_FALLBACK_DEADLINE.as_millis()
769 )));
770 }
771 std::thread::sleep(KILL_POLL_INTERVAL);
772 }
773 }
774}
775
776const KILL_FALLBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
778
779const KILL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
781
782#[cfg(target_os = "linux")]
785fn write_existing(path: &Path, value: &str) -> std::io::Result<()> {
786 use std::io::Write as _;
787 let file = std::fs::OpenOptions::new().write(true).open(path)?;
788 (&file).write_all(value.as_bytes())
789}
790
791#[cfg(all(test, target_os = "linux"))]
794thread_local! {
795 pub(crate) static FORCE_KILL_FALLBACK: std::cell::Cell<bool> =
796 const { std::cell::Cell::new(false) };
797}
798
799impl Drop for CgroupScope {
800 fn drop(&mut self) {
808 if self.path.as_os_str().is_empty() {
809 return;
810 }
811 if !self.member_pids().is_empty() {
812 let _ = self.kill_all();
813 self.wait_until_empty(KILL_FALLBACK_DEADLINE);
814 }
815 self.procs_fd = None;
818 #[cfg(target_os = "linux")]
819 {
820 self.watcher = None;
821 }
822 if let Err(error) = std::fs::remove_dir(&self.path) {
823 crate::runtime_err!(
824 "[capsule-runtime] note: could not remove cgroup scope {} ({error}); \
825 it will be reclaimed once its last task exits",
826 self.path.display()
827 );
828 }
829 }
830}
831
832#[cfg(target_os = "linux")]
837#[derive(Debug)]
838struct ScopeWatcher {
839 lifeline: std::sync::Mutex<Option<std::os::fd::OwnedFd>>,
843 pid: libc::pid_t,
844}
845
846#[cfg(target_os = "linux")]
850#[derive(Clone, Copy)]
851struct WatcherKill {
852 kill: libc::c_int,
853 freeze: libc::c_int,
854 procs: libc::c_int,
855}
856
857#[cfg(target_os = "linux")]
858impl ScopeWatcher {
859 #[allow(unsafe_code)]
863 fn spawn(scope: &Path) -> std::io::Result<Self> {
864 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
865
866 #[cfg(test)]
867 let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
868 #[cfg(not(test))]
869 let forced = false;
870 let kill = if forced {
871 None
872 } else {
873 open_if_present(&scope.join("cgroup.kill"), libc::O_WRONLY)?
874 };
875 let (freeze, procs) = match kill {
876 Some(_) => (None, None),
877 None => (
878 open_if_present(&scope.join("cgroup.freeze"), libc::O_WRONLY)?,
879 Some(open_with(&scope.join("cgroup.procs"), libc::O_RDONLY)?),
880 ),
881 };
882 let devnull = open_with(Path::new("/dev/null"), libc::O_RDWR)?;
883
884 let mut ends = [0 as libc::c_int; 2];
885 if unsafe { libc::pipe2(ends.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
887 return Err(std::io::Error::last_os_error());
888 }
889 let (read_end, write_end) =
892 unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
893
894 let raw = |fd: &Option<OwnedFd>| fd.as_ref().map_or(-1, AsRawFd::as_raw_fd);
895 let plan = WatcherKill {
896 kill: raw(&kill),
897 freeze: raw(&freeze),
898 procs: raw(&procs),
899 };
900 let mut keep = [-1 as libc::c_int; 7];
904 let mut kept = 0;
905 for fd in [
906 0,
907 1,
908 2,
909 read_end.as_raw_fd(),
910 plan.kill,
911 plan.freeze,
912 plan.procs,
913 ] {
914 if fd >= 0 {
915 keep[kept] = fd;
916 kept += 1;
917 }
918 }
919 keep[..kept].sort_unstable();
920 let devnull_fd = devnull.as_raw_fd();
921 let read_fd = read_end.as_raw_fd();
922 let deadline_ns = KILL_FALLBACK_DEADLINE.as_nanos() as i64;
923
924 let pid = unsafe { libc::fork() };
930 if pid < 0 {
931 return Err(std::io::Error::last_os_error());
932 }
933 if pid == 0 {
934 unsafe { watch(devnull_fd, &keep[..kept], read_fd, plan, deadline_ns) }
936 }
937 Ok(Self {
938 lifeline: std::sync::Mutex::new(Some(write_end)),
939 pid,
940 })
941 }
942
943 #[cfg(test)]
945 fn cut_lifeline(&self) {
946 self.lifeline
947 .lock()
948 .unwrap_or_else(std::sync::PoisonError::into_inner)
949 .take();
950 }
951}
952
953#[cfg(target_os = "linux")]
954impl Drop for ScopeWatcher {
955 #[allow(unsafe_code)]
958 fn drop(&mut self) {
959 self.lifeline
960 .get_mut()
961 .unwrap_or_else(std::sync::PoisonError::into_inner)
962 .take();
963 let mut status = 0;
964 loop {
965 let waited = unsafe { libc::waitpid(self.pid, &mut status, 0) };
967 if waited >= 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
968 return;
969 }
970 }
971 }
972}
973
974#[cfg(target_os = "linux")]
976fn open_with(path: &Path, flags: libc::c_int) -> std::io::Result<std::os::fd::OwnedFd> {
977 use std::os::unix::fs::OpenOptionsExt;
978
979 std::fs::OpenOptions::new()
980 .read(flags & libc::O_ACCMODE != libc::O_WRONLY)
981 .write(flags & libc::O_ACCMODE != libc::O_RDONLY)
982 .custom_flags(libc::O_CLOEXEC)
983 .open(path)
984 .map(std::os::fd::OwnedFd::from)
985}
986
987#[cfg(target_os = "linux")]
989fn open_if_present(
990 path: &Path,
991 flags: libc::c_int,
992) -> std::io::Result<Option<std::os::fd::OwnedFd>> {
993 match open_with(path, flags) {
994 Ok(fd) => Ok(Some(fd)),
995 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
996 Err(error) => Err(error),
997 }
998}
999
1000#[cfg(target_os = "linux")]
1009#[allow(unsafe_code)]
1010unsafe fn watch(
1011 devnull: libc::c_int,
1012 keep: &[libc::c_int],
1013 read_fd: libc::c_int,
1014 plan: WatcherKill,
1015 deadline_ns: i64,
1016) -> ! {
1017 unsafe {
1020 for target in 0..3 {
1022 libc::dup2(devnull, target);
1023 }
1024 let mut low: libc::c_uint = 0;
1025 for &fd in keep {
1026 let fd = fd as libc::c_uint;
1027 if fd > low {
1028 close_range(low, fd - 1);
1029 }
1030 low = fd + 1;
1031 }
1032 close_range(low, libc::c_uint::MAX);
1033
1034 libc::setpgid(0, 0);
1035 let mut ignore: libc::sigaction = std::mem::zeroed();
1036 ignore.sa_sigaction = libc::SIG_IGN;
1037 for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGPIPE] {
1038 libc::sigaction(signal, &ignore, std::ptr::null_mut());
1039 }
1040
1041 let mut byte = [0u8; 1];
1042 loop {
1043 let read = libc::read(read_fd, byte.as_mut_ptr().cast(), 1);
1044 if read == 0 {
1045 break;
1046 }
1047 if read < 0 && *libc::__errno_location() != libc::EINTR {
1048 break;
1049 }
1050 }
1051
1052 kill_scope(plan, deadline_ns);
1053 libc::_exit(0)
1054 }
1055}
1056
1057#[cfg(target_os = "linux")]
1064#[allow(unsafe_code)]
1065unsafe fn close_range(low: libc::c_uint, high: libc::c_uint) {
1066 unsafe {
1068 if libc::syscall(libc::SYS_close_range, low, high, 0 as libc::c_uint) == 0 {
1069 return;
1070 }
1071 let mut limit: libc::rlimit = std::mem::zeroed();
1072 let ceiling = if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) == 0 {
1073 limit.rlim_cur.min(libc::c_uint::MAX as libc::rlim_t) as libc::c_uint
1074 } else {
1075 1 << 20
1076 };
1077 let mut fd = low;
1078 while fd <= high && fd < ceiling {
1079 libc::close(fd as libc::c_int);
1080 fd += 1;
1081 }
1082 }
1083}
1084
1085#[cfg(target_os = "linux")]
1092#[allow(unsafe_code)]
1093unsafe fn kill_scope(plan: WatcherKill, deadline_ns: i64) {
1094 unsafe {
1096 if plan.kill >= 0 {
1097 write_control(plan.kill, b"1");
1098 return;
1099 }
1100 let start = monotonic_ns();
1101 loop {
1102 if plan.freeze >= 0 {
1103 write_control(plan.freeze, b"1");
1104 }
1105 let signalled = signal_listed(plan.procs);
1106 if plan.freeze >= 0 {
1107 write_control(plan.freeze, b"0");
1108 }
1109 if signalled == 0 || monotonic_ns() - start >= deadline_ns {
1110 return;
1111 }
1112 let pause = libc::timespec {
1113 tv_sec: 0,
1114 tv_nsec: KILL_POLL_INTERVAL.as_nanos() as libc::c_long,
1115 };
1116 libc::nanosleep(&pause, std::ptr::null_mut());
1117 }
1118 }
1119}
1120
1121#[cfg(target_os = "linux")]
1127#[allow(unsafe_code)]
1128unsafe fn write_control(fd: libc::c_int, value: &[u8]) {
1129 unsafe {
1131 libc::lseek(fd, 0, libc::SEEK_SET);
1132 libc::write(fd, value.as_ptr().cast(), value.len());
1133 }
1134}
1135
1136#[cfg(target_os = "linux")]
1143#[allow(unsafe_code)]
1144unsafe fn signal_listed(procs: libc::c_int) -> usize {
1145 unsafe {
1147 if procs < 0 || libc::lseek(procs, 0, libc::SEEK_SET) < 0 {
1148 return 0;
1149 }
1150 let mut buf = [0u8; 512];
1151 let mut pid: libc::pid_t = 0;
1152 let mut digits = false;
1153 let mut signalled = 0;
1154 loop {
1155 let read = libc::read(procs, buf.as_mut_ptr().cast(), buf.len());
1156 if read < 0 && *libc::__errno_location() == libc::EINTR {
1157 continue;
1158 }
1159 if read <= 0 {
1160 break;
1161 }
1162 for &byte in &buf[..read as usize] {
1163 if byte.is_ascii_digit() {
1164 pid = pid
1165 .saturating_mul(10)
1166 .saturating_add(libc::pid_t::from(byte - b'0'));
1167 digits = true;
1168 } else {
1169 if digits && pid > 0 {
1170 libc::kill(pid, libc::SIGKILL);
1171 signalled += 1;
1172 }
1173 pid = 0;
1174 digits = false;
1175 }
1176 }
1177 }
1178 if digits && pid > 0 {
1179 libc::kill(pid, libc::SIGKILL);
1180 signalled += 1;
1181 }
1182 signalled
1183 }
1184}
1185
1186#[cfg(target_os = "linux")]
1192#[allow(unsafe_code)]
1193unsafe fn monotonic_ns() -> i64 {
1194 unsafe {
1196 let mut now: libc::timespec = std::mem::zeroed();
1197 libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now);
1198 now.tv_sec as i64 * 1_000_000_000 + now.tv_nsec as i64
1199 }
1200}
1201
1202#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1207fn format_pid(pid: i32, buf: &mut [u8; 24]) -> usize {
1208 let mut digits = [0u8; 20];
1209 let mut count = 0;
1210 let mut value = pid.unsigned_abs() as u64;
1211 if value == 0 {
1212 digits[0] = b'0';
1213 count = 1;
1214 }
1215 while value > 0 {
1216 digits[count] = b'0' + (value % 10) as u8;
1217 value /= 10;
1218 count += 1;
1219 }
1220
1221 let mut written = 0;
1222 for index in (0..count).rev() {
1223 buf[written] = digits[index];
1224 written += 1;
1225 }
1226 buf[written] = b'\n';
1227 written + 1
1228}
1229
1230#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1235fn read_event_counter(path: &Path, key: &str) -> u64 {
1236 let Ok(contents) = std::fs::read_to_string(path) else {
1237 return 0;
1238 };
1239 parse_event_counter(&contents, key)
1240}
1241
1242#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1244fn parse_event_counter(contents: &str, key: &str) -> u64 {
1245 for line in contents.lines() {
1246 let mut parts = line.split_whitespace();
1247 if parts.next() == Some(key) {
1248 return parts
1249 .next()
1250 .and_then(|value| value.parse().ok())
1251 .unwrap_or(0);
1252 }
1253 }
1254 0
1255}
1256
1257#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1269fn scope_dir_name(session_id: &str) -> String {
1270 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1271 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1272
1273 let sanitized: String = session_id
1274 .chars()
1275 .map(|c| {
1276 if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
1277 c
1278 } else {
1279 '_'
1280 }
1281 })
1282 .collect();
1283 let pid = std::process::id();
1284 if sanitized.is_empty() {
1285 format!("murmur-{pid}-{serial}")
1286 } else {
1287 format!("murmur-{sanitized}-{pid}-{serial}")
1288 }
1289}
1290
1291#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1297fn parse_unified_cgroup_path(contents: &str) -> Option<String> {
1298 for line in contents.lines() {
1299 let mut parts = line.splitn(3, ':');
1300 let (Some("0"), Some(""), Some(path)) = (parts.next(), parts.next(), parts.next()) else {
1301 continue;
1302 };
1303 if path.starts_with('/') {
1304 return Some(path.to_string());
1305 }
1306 }
1307 None
1308}
1309
1310#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1313fn parse_cgroup2_mount(contents: &str) -> String {
1314 for line in contents.lines() {
1315 let mut parts = line.split_whitespace();
1316 let (Some(_source), Some(target), Some(fstype)) =
1317 (parts.next(), parts.next(), parts.next())
1318 else {
1319 continue;
1320 };
1321 if fstype == "cgroup2" {
1322 return target.to_string();
1323 }
1324 }
1325 DEFAULT_CGROUP2_MOUNT.to_string()
1326}
1327
1328#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1330fn missing_controllers(subtree_control: &str, wanted: &[&str]) -> Vec<String> {
1331 let enabled: Vec<&str> = subtree_control.split_whitespace().collect();
1332 wanted
1333 .iter()
1334 .filter(|controller| !enabled.contains(*controller))
1335 .map(|controller| (*controller).to_string())
1336 .collect()
1337}
1338
1339#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1342fn device_major_minor(dev: u64) -> (u64, u64) {
1343 let major = ((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff);
1344 let minor = (dev & 0xff) | ((dev >> 12) & !0xff);
1345 (major, minor)
1346}
1347
1348#[cfg(target_os = "linux")]
1349impl CgroupScope {
1350 fn create(
1357 limits: &HostResourceLimits,
1358 session_id: &str,
1359 workdir: &Path,
1360 ) -> Result<(Self, IoMaxReport), String> {
1361 let base = delegated_base()?;
1362
1363 let path = base.join(scope_dir_name(session_id));
1364 if let Err(error) = std::fs::create_dir(&path) {
1365 if error.kind() != std::io::ErrorKind::AlreadyExists {
1366 return Err(format!(
1367 "could not create cgroup scope at {}: {error}",
1368 path.display()
1369 ));
1370 }
1371 }
1372
1373 let scope = Self {
1374 path,
1375 procs_fd: None,
1376 occupancy: Arc::default(),
1377 watcher: None,
1378 };
1379
1380 for limit in scope_limits(limits) {
1381 scope.write_limit(&limit)?;
1382 }
1383 let io_max = scope.apply_io_max(limits.cgroup_io_bytes_per_sec, workdir);
1384
1385 let procs_fd = open_write_only(&scope.path.join("cgroup.procs")).map_err(|error| {
1388 format!(
1389 "could not open {}/cgroup.procs for subprocess placement: {error}",
1390 scope.path.display()
1391 )
1392 })?;
1393
1394 let watcher = ScopeWatcher::spawn(&scope.path).map_err(|error| {
1395 format!(
1396 "could not start the watcher that kills {} if the runtime dies: {error}",
1397 scope.path.display()
1398 )
1399 })?;
1400
1401 Ok((
1402 Self {
1403 path: scope.take_path(),
1404 procs_fd: Some(procs_fd),
1405 occupancy: Arc::default(),
1406 watcher: Some(watcher),
1407 },
1408 io_max,
1409 ))
1410 }
1411
1412 fn take_path(mut self) -> PathBuf {
1415 let path = std::mem::take(&mut self.path);
1416 path
1418 }
1419
1420 fn write_limit(&self, limit: &ScopeLimit) -> Result<(), String> {
1423 let path = self.path.join(limit.file);
1424 let value = &limit.value;
1425 match write_existing(&path, &format!("{value}\n")) {
1426 Err(error) if limit.absent_ok && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1427 written => written
1428 .map_err(|error| format!("could not write {value} to {}: {error}", path.display())),
1429 }
1430 }
1431
1432 fn apply_io_max(&self, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1444 write_io_max(&self.path, bytes_per_sec, workdir)
1445 }
1446}
1447
1448#[cfg(target_os = "linux")]
1458fn write_io_max(scope_path: &Path, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1459 let (major, minor) = match io_max_device(workdir) {
1460 Ok(device) => device,
1461 Err(reason) => return IoMaxReport::unavailable(bytes_per_sec, reason),
1462 };
1463 let value = format!("{major}:{minor} rbps={bytes_per_sec} wbps={bytes_per_sec}");
1464 match std::fs::write(scope_path.join("io.max"), format!("{value}\n")) {
1465 Ok(()) => IoMaxReport::enforced(bytes_per_sec),
1466 Err(error) => IoMaxReport::unavailable(
1472 bytes_per_sec,
1473 format!("writing `{value}` to the scope's io.max: {error}"),
1474 ),
1475 }
1476}
1477
1478#[cfg(target_os = "linux")]
1496fn io_max_device(path: &Path) -> Result<(u64, u64), String> {
1497 use std::os::linux::fs::MetadataExt;
1498
1499 let Some(existing) = nearest_existing_ancestor(path) else {
1500 return Err(format!(
1501 "could not stat {} or any ancestor of it to find its backing device",
1502 path.display()
1503 ));
1504 };
1505 let metadata = existing.metadata().map_err(|error| {
1506 format!(
1507 "could not stat {} to find its backing device: {error}",
1508 existing.display()
1509 )
1510 })?;
1511 let (major, minor) = device_major_minor(metadata.st_dev());
1512 let (major, minor) = if major == 0 {
1513 mount_source_device(existing)?
1514 } else {
1515 (major, minor)
1516 };
1517 Ok(whole_disk_device(major, minor))
1518}
1519
1520#[cfg(target_os = "linux")]
1526fn mount_source_device(path: &Path) -> Result<(u64, u64), String> {
1527 use std::os::unix::fs::{FileTypeExt, MetadataExt};
1528
1529 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1530 let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(|error| {
1531 format!(
1532 "reading /proc/self/mountinfo to find the device behind {}: {error}",
1533 resolved.display()
1534 )
1535 })?;
1536 let Some(mount) = mount_covering(&mountinfo, &resolved) else {
1537 return Err(format!(
1538 "no mount in /proc/self/mountinfo covers {}, so its backing device is unknown",
1539 resolved.display()
1540 ));
1541 };
1542 match std::fs::metadata(&mount.source) {
1543 Ok(metadata) if metadata.file_type().is_block_device() => {
1544 Ok(device_major_minor(metadata.rdev()))
1545 }
1546 _ => Err(format!(
1547 "the filesystem mounted at {} is backed by `{}`, which is not a block device, so no \
1548 io.max ceiling can name one",
1549 mount.mount_point.display(),
1550 mount.source.display()
1551 )),
1552 }
1553}
1554
1555#[cfg(target_os = "linux")]
1563fn whole_disk_device(major: u64, minor: u64) -> (u64, u64) {
1564 let device = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
1565 if !device.join("partition").exists() {
1566 return (major, minor);
1567 }
1568 std::fs::read_to_string(device.join("../dev"))
1569 .ok()
1570 .and_then(|contents| parse_dev_line(&contents))
1571 .unwrap_or((major, minor))
1572}
1573
1574#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1576fn parse_dev_line(contents: &str) -> Option<(u64, u64)> {
1577 let (major, minor) = contents.trim().split_once(':')?;
1578 Some((major.parse().ok()?, minor.parse().ok()?))
1579}
1580
1581#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1584#[derive(Debug, PartialEq, Eq)]
1585struct MountEntry {
1586 mount_point: PathBuf,
1588 source: PathBuf,
1591}
1592
1593#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1598fn mount_covering(mountinfo: &str, path: &Path) -> Option<MountEntry> {
1599 let mut best: Option<(usize, MountEntry)> = None;
1600 for line in mountinfo.lines() {
1601 let Some(entry) = parse_mount_entry(line) else {
1602 continue;
1603 };
1604 if !path.starts_with(&entry.mount_point) {
1605 continue;
1606 }
1607 let depth = entry.mount_point.components().count();
1608 if best.as_ref().is_none_or(|(best, _)| depth >= *best) {
1609 best = Some((depth, entry));
1610 }
1611 }
1612 best.map(|(_, entry)| entry)
1613}
1614
1615#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1620fn parse_mount_entry(line: &str) -> Option<MountEntry> {
1621 let (mounted, backing) = line.split_once(" - ")?;
1622 let mount_point = mounted.split_whitespace().nth(4)?;
1623 let source = backing.split_whitespace().nth(1)?;
1624 Some(MountEntry {
1625 mount_point: unescape_octal(mount_point),
1626 source: unescape_octal(source),
1627 })
1628}
1629
1630#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1633fn unescape_octal(field: &str) -> PathBuf {
1634 use std::os::unix::ffi::OsStringExt;
1635
1636 let bytes = field.as_bytes();
1637 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
1638 let mut index = 0;
1639 while index < bytes.len() {
1640 let escape = bytes[index] == b'\\'
1641 && index + 3 < bytes.len()
1642 && bytes[index + 1..index + 4]
1643 .iter()
1644 .all(|byte| (b'0'..=b'7').contains(byte));
1645 if escape {
1646 let digits = &field[index + 1..index + 4];
1647 out.push(u8::from_str_radix(digits, 8).expect("three octal digits"));
1648 index += 4;
1649 } else {
1650 out.push(bytes[index]);
1651 index += 1;
1652 }
1653 }
1654 PathBuf::from(std::ffi::OsString::from_vec(out))
1655}
1656
1657#[cfg(target_os = "linux")]
1663fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
1664 let mut candidate = Some(path);
1665 while let Some(current) = candidate {
1666 if current.exists() {
1667 return Some(current);
1668 }
1669 candidate = current.parent();
1670 }
1671 None
1672}
1673
1674#[cfg(target_os = "linux")]
1688fn delegated_base() -> Result<PathBuf, String> {
1689 static BASE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
1690
1691 let mut cached = BASE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1694 if let Some(base) = cached.as_ref() {
1695 return Ok(base.clone());
1696 }
1697
1698 move_self_into_own_systemd_scope();
1701
1702 let base = probe_delegation()?;
1703 enable_controllers(&base)?;
1704 *cached = Some(base.clone());
1705 Ok(base)
1706}
1707
1708#[cfg(target_os = "linux")]
1718fn move_self_into_own_systemd_scope() {
1719 static ATTEMPTED: std::sync::Once = std::sync::Once::new();
1720 ATTEMPTED.call_once(|| {
1721 let _ = request_transient_scope(&transient_scope_unit_name());
1722 });
1723}
1724
1725#[cfg(target_os = "linux")]
1732fn transient_scope_unit_name() -> String {
1733 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1734 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1735 format!("murmur-{}-{serial}.scope", std::process::id())
1736}
1737
1738#[cfg(target_os = "linux")]
1741fn request_transient_scope(unit: &str) -> Result<(), String> {
1742 let owned_unit = unit.to_string();
1745 std::thread::Builder::new()
1746 .name("murmur-cgroup-scope".to_string())
1747 .spawn(move || start_transient_scope(&owned_unit))
1748 .map_err(|error| format!("could not spawn the D-Bus thread: {error}"))?
1749 .join()
1750 .map_err(|_| "the D-Bus thread panicked".to_string())??;
1751
1752 await_scope_membership(unit)
1753}
1754
1755#[cfg(target_os = "linux")]
1771fn start_transient_scope(unit: &str) -> Result<(), String> {
1772 use zbus::Variant;
1773
1774 let properties: Vec<(&str, Variant<'_>)> = vec![
1775 (
1776 "Description",
1777 Variant::from("Murmur capsule subprocess scope"),
1778 ),
1779 ("PIDs", Variant::from(vec![std::process::id()])),
1780 ("Delegate", Variant::from(true)),
1781 ("CollectMode", Variant::from("inactive-or-failed")),
1782 ];
1783 let aux: Vec<(&str, Vec<(&str, Variant<'_>)>)> = Vec::new();
1785
1786 zbus::block_on(async {
1787 let connection = zbus::Connection::session()
1788 .await
1789 .map_err(|error| format!("no systemd session bus: {error}"))?;
1790 connection
1791 .call_method(
1792 Some("org.freedesktop.systemd1"),
1793 "/org/freedesktop/systemd1",
1794 Some("org.freedesktop.systemd1.Manager"),
1795 "StartTransientUnit",
1796 &(unit, "fail", properties, aux),
1799 )
1800 .await
1801 .map(|_| ())
1802 .map_err(|error| format!("systemd refused to start {unit}: {error}"))
1803 })
1804}
1805
1806#[cfg(target_os = "linux")]
1820fn await_scope_membership(unit: &str) -> Result<(), String> {
1821 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1822 loop {
1823 let current = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
1824 if let Some(path) = parse_unified_cgroup_path(¤t) {
1825 if path.rsplit('/').next() == Some(unit) {
1826 return Ok(());
1827 }
1828 }
1829 if std::time::Instant::now() >= deadline {
1830 return Err(format!(
1831 "systemd accepted the request for {unit} but this process is not in it"
1832 ));
1833 }
1834 std::thread::sleep(Duration::from_millis(1));
1835 }
1836}
1837
1838#[cfg(target_os = "linux")]
1846fn probe_delegation() -> Result<PathBuf, String> {
1847 let self_cgroup = std::fs::read_to_string("/proc/self/cgroup")
1848 .map_err(|error| format!("could not read /proc/self/cgroup: {error}"))?;
1849 let relative = parse_unified_cgroup_path(&self_cgroup).ok_or_else(|| {
1850 "this host has no cgroup v2 unified hierarchy (no `0::` line in /proc/self/cgroup); \
1851 cgroup v1-only and hybrid hosts cannot be delegated a v2 scope"
1852 .to_string()
1853 })?;
1854
1855 let mount = std::fs::read_to_string("/proc/mounts")
1856 .map(|contents| parse_cgroup2_mount(&contents))
1857 .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
1858
1859 let base = ascend_out_of_supervisor_leaves(
1860 PathBuf::from(mount).join(relative.trim_start_matches('/')),
1861 );
1862 if !base.is_dir() {
1863 return Err(format!(
1864 "delegated cgroup directory {} does not exist",
1865 base.display()
1866 ));
1867 }
1868
1869 let controllers =
1870 std::fs::read_to_string(base.join("cgroup.controllers")).map_err(|error| {
1871 format!(
1872 "could not read {}/cgroup.controllers: {error}",
1873 base.display()
1874 )
1875 })?;
1876 let unavailable = missing_controllers(&controllers, REQUIRED_CONTROLLERS);
1877 if !unavailable.is_empty() {
1878 return Err(format!(
1879 "cgroup controllers [{}] are not delegated to {}; the unit `mur` runs under needs \
1880 `Delegate=yes` (or `Delegate=memory pids cpu io`)",
1881 unavailable.join(", "),
1882 base.display()
1883 ));
1884 }
1885
1886 let probe_dir = base.join(format!("murmur-delegation-probe-{}", std::process::id()));
1890 let _ = std::fs::remove_dir(&probe_dir);
1891 std::fs::create_dir(&probe_dir).map_err(|error| {
1892 format!(
1893 "cannot create a child cgroup under {} ({error}); the unit `mur` runs under needs \
1894 `Delegate=yes` for memory, pids, cpu and io",
1895 base.display()
1896 )
1897 })?;
1898 let _ = std::fs::remove_dir(&probe_dir);
1899
1900 Ok(base)
1901}
1902
1903#[cfg(target_os = "linux")]
1910fn enable_controllers(base: &Path) -> Result<(), String> {
1911 let subtree_control = base.join("cgroup.subtree_control");
1912 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
1913
1914 let available = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
1917 let mut wanted: Vec<&str> = REQUIRED_CONTROLLERS.to_vec();
1918 if available.split_whitespace().any(|c| c == "io") {
1919 wanted.push("io");
1920 }
1921
1922 let missing = missing_controllers(¤t, &wanted);
1923 if missing.is_empty() {
1924 return Ok(());
1925 }
1926 let directive = missing
1927 .iter()
1928 .map(|controller| format!("+{controller}"))
1929 .collect::<Vec<_>>()
1930 .join(" ");
1931
1932 if std::fs::write(&subtree_control, format!("{directive}\n")).is_ok() {
1933 return Ok(());
1934 }
1935
1936 move_self_to_supervisor_leaf(base)?;
1937
1938 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1949 let mut last_error;
1952 loop {
1953 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
1954 if missing_controllers(¤t, &wanted).is_empty() {
1955 return Ok(());
1956 }
1957 match std::fs::write(&subtree_control, format!("{directive}\n")) {
1958 Ok(()) => return Ok(()),
1959 Err(error) => last_error = Some(error),
1960 }
1961 if std::time::Instant::now() >= deadline {
1962 break;
1963 }
1964 std::thread::sleep(Duration::from_millis(20));
1965 }
1966
1967 Err(format!(
1968 "could not enable controllers [{}] in {} ({}); the unit `mur` runs under needs \
1969 `Delegate=yes` for memory, pids, cpu and io",
1970 missing.join(", "),
1971 subtree_control.display(),
1972 last_error.map_or_else(|| "still occupied".to_string(), |error| error.to_string()),
1973 ))
1974}
1975
1976#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1987fn ascend_out_of_supervisor_leaves(mut base: PathBuf) -> PathBuf {
1988 while base.file_name().and_then(|name| name.to_str()) == Some(SUPERVISOR_LEAF) {
1989 base.pop();
1990 }
1991 base
1992}
1993
1994#[cfg(target_os = "linux")]
1996fn move_self_to_supervisor_leaf(base: &Path) -> Result<(), String> {
1997 let leaf = base.join(SUPERVISOR_LEAF);
1998 if let Err(error) = std::fs::create_dir(&leaf) {
1999 if error.kind() != std::io::ErrorKind::AlreadyExists {
2000 return Err(format!(
2001 "could not create {} to vacate the delegated cgroup: {error}",
2002 leaf.display()
2003 ));
2004 }
2005 }
2006 std::fs::write(
2007 leaf.join("cgroup.procs"),
2008 format!("{}\n", std::process::id()),
2009 )
2010 .map_err(|error| {
2011 format!(
2012 "could not move this process into {} to vacate the delegated cgroup: {error}",
2013 leaf.display()
2014 )
2015 })
2016}
2017
2018#[cfg(target_os = "linux")]
2024#[allow(unsafe_code)]
2025fn open_write_only(path: &Path) -> std::io::Result<std::os::fd::OwnedFd> {
2026 use std::os::unix::fs::OpenOptionsExt;
2027
2028 std::fs::OpenOptions::new()
2029 .write(true)
2030 .custom_flags(libc::O_CLOEXEC)
2031 .open(path)
2032 .map(std::os::fd::OwnedFd::from)
2033}
2034
2035pub fn cgroup_delegation_available() -> bool {
2050 #[cfg(not(target_os = "linux"))]
2051 {
2052 true
2053 }
2054
2055 #[cfg(target_os = "linux")]
2056 {
2057 delegated_base().is_ok()
2063 }
2064}
2065
2066#[must_use]
2075pub fn io_max_device_available(path: &Path) -> bool {
2076 #[cfg(not(target_os = "linux"))]
2077 {
2078 let _ = path;
2079 false
2080 }
2081
2082 #[cfg(target_os = "linux")]
2083 {
2084 io_max_device(path).is_ok()
2085 }
2086}
2087
2088pub fn skip_without_host_support(test_name: &str) -> bool {
2096 if crate::network_namespace::skip_without_egress_namespace(test_name) {
2097 return true;
2098 }
2099 if !cgroup_delegation_available() {
2100 crate::runtime_err!(
2101 "[SKIP-HOST] {test_name}: this host cannot delegate a cgroup v2 scope, so a capsule \
2102 that can spawn native subprocesses refuses to launch with E-RUN-012 before anything \
2103 this test observes happens -- see {}",
2104 murmur_artifact::docs_reference_url!("resource-limits/#platform-behavior")
2105 );
2106 return true;
2107 }
2108 false
2109}
2110
2111#[cfg(test)]
2112mod tests {
2113 use super::*;
2114 use crate::types::CapabilityPolicy;
2115
2116 #[test]
2120 fn io_max_status_wire_names_are_kebab_case() {
2121 let spellings: Vec<String> = [
2122 IoMaxStatus::Enforced,
2123 IoMaxStatus::Unavailable,
2124 IoMaxStatus::NotRequired,
2125 IoMaxStatus::NotProbed,
2126 ]
2127 .into_iter()
2128 .map(|status| {
2129 serde_json::to_value(status)
2130 .unwrap()
2131 .as_str()
2132 .unwrap()
2133 .to_string()
2134 })
2135 .collect();
2136 assert_eq!(
2137 spellings,
2138 vec!["enforced", "unavailable", "not-required", "not-probed"]
2139 );
2140 }
2141
2142 #[test]
2145 fn an_unfilled_report_claims_nothing() {
2146 let report = IoMaxReport::default();
2147 assert_eq!(report.status, IoMaxStatus::NotProbed);
2148 assert_eq!(report.declared_bytes_per_sec, 0);
2149 assert_eq!(report.reason, None);
2150 assert_eq!(io_max_warning(&report), None);
2151 }
2152
2153 #[test]
2155 fn only_a_failed_write_omits_nothing_and_a_successful_one_explains_nothing() {
2156 let enforced = serde_json::to_value(IoMaxReport::enforced(1_000)).unwrap();
2157 assert_eq!(enforced["status"], "enforced");
2158 assert_eq!(enforced["declared_bytes_per_sec"], 1_000);
2159 assert!(
2160 enforced.get("reason").is_none(),
2161 "an applied ceiling has nothing to explain: {enforced}"
2162 );
2163
2164 let unavailable =
2165 serde_json::to_value(IoMaxReport::unavailable(1_000, "no io controller")).unwrap();
2166 assert_eq!(unavailable["status"], "unavailable");
2167 assert_eq!(unavailable["reason"], "no io controller");
2168 }
2169
2170 #[test]
2174 fn the_warning_fires_only_where_a_declared_ceiling_did_not_apply() {
2175 assert_eq!(io_max_warning(&IoMaxReport::enforced(4_096)), None);
2176 assert_eq!(
2177 io_max_warning(&IoMaxReport::not_required(4_096, NO_SCOPE_REASON)),
2178 None
2179 );
2180
2181 let (code, message) = io_max_warning(&IoMaxReport::unavailable(
2182 4_096,
2183 "device 0:24 is not a block device",
2184 ))
2185 .expect("an unapplied ceiling must be reported");
2186 assert_eq!(code, murmur_artifact::W_SEC_021);
2187 assert!(message.contains("4096 bytes/s"), "message was: {message}");
2188 assert!(
2189 message.contains("device 0:24 is not a block device"),
2190 "message was: {message}"
2191 );
2192 assert!(message.contains("memory.max"), "message was: {message}");
2193 }
2194
2195 #[cfg(target_os = "linux")]
2198 #[test]
2199 fn the_device_is_resolved_from_the_nearest_existing_ancestor() {
2200 let temp = tempfile::tempdir().unwrap();
2201 let missing = temp
2202 .path()
2203 .join("workdir")
2204 .join("does")
2205 .join("not")
2206 .join("exist");
2207 assert_eq!(
2208 nearest_existing_ancestor(&missing),
2209 Some(temp.path()),
2210 "an ancestor of a tempdir always exists"
2211 );
2212 assert_eq!(
2213 io_max_device(&missing).ok(),
2214 io_max_device(temp.path()).ok(),
2215 "a path that does not exist yet must resolve to its filesystem's device"
2216 );
2217 assert!(!missing.exists(), "resolving a device must create nothing");
2218 }
2219
2220 #[test]
2223 fn the_covering_mount_is_the_deepest_one() {
2224 let mountinfo = "\
222525 30 0:29 / / rw,relatime shared:1 - btrfs /dev/nvme0n1p5 rw,subvol=/
222626 25 0:31 / /dev/shm rw,nosuid shared:2 - tmpfs tmpfs rw,inode64
222727 25 0:41 / /space rw,relatime shared:3 - btrfs /dev/nvme0n1p7 rw,subvol=/
2228";
2229 let covering = |path: &str| mount_covering(mountinfo, Path::new(path)).unwrap();
2230
2231 assert_eq!(
2232 covering("/space/project/workdir").source,
2233 PathBuf::from("/dev/nvme0n1p7")
2234 );
2235 assert_eq!(covering("/dev/shm/project").source, PathBuf::from("tmpfs"));
2236 assert_eq!(
2237 covering("/home/someone").source,
2238 PathBuf::from("/dev/nvme0n1p5"),
2239 "a path under no nested mount belongs to the root filesystem"
2240 );
2241 assert_eq!(
2242 covering("/spaceship").source,
2243 PathBuf::from("/dev/nvme0n1p5"),
2244 "mount points match whole path components, not string prefixes"
2245 );
2246 }
2247
2248 #[test]
2251 fn a_mount_over_another_at_the_same_point_wins() {
2252 let mountinfo = "\
225325 30 0:29 / / rw shared:1 - ext4 /dev/sda1 rw
225426 25 0:31 / /mnt rw shared:2 - ext4 /dev/sdb1 rw
225527 25 0:32 / /mnt rw shared:3 - ext4 /dev/sdc1 rw
2256";
2257 assert_eq!(
2258 mount_covering(mountinfo, Path::new("/mnt/data"))
2259 .unwrap()
2260 .source,
2261 PathBuf::from("/dev/sdc1")
2262 );
2263 }
2264
2265 #[test]
2268 fn mount_point_escapes_are_decoded() {
2269 let mountinfo = "\
227025 30 0:29 / /media/a\\040disk rw shared:1 - ext4 /dev/sdb1 rw
2271";
2272 let entry = mount_covering(mountinfo, Path::new("/media/a disk/project")).unwrap();
2273 assert_eq!(entry.mount_point, PathBuf::from("/media/a disk"));
2274 assert_eq!(entry.source, PathBuf::from("/dev/sdb1"));
2275 assert_eq!(unescape_octal("no\\134escape"), PathBuf::from("no\\escape"));
2276 assert_eq!(
2277 unescape_octal("\\04"),
2278 PathBuf::from("\\04"),
2279 "a truncated escape is not an escape"
2280 );
2281 }
2282
2283 #[test]
2285 fn sysfs_dev_attributes_are_read_as_major_minor() {
2286 assert_eq!(parse_dev_line("259:0\n"), Some((259, 0)));
2287 assert_eq!(parse_dev_line(""), None);
2288 assert_eq!(parse_dev_line("259"), None);
2289 }
2290
2291 #[cfg(target_os = "linux")]
2295 #[test]
2296 fn the_resolved_device_is_never_a_partition() {
2297 let Ok((major, minor)) = io_max_device(Path::new(".")) else {
2298 return;
2301 };
2302 let sysfs = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
2303 assert!(
2304 !sysfs.join("partition").exists(),
2305 "io.max was given the partition {major}:{minor}, which the block layer refuses"
2306 );
2307 }
2308
2309 #[cfg(target_os = "linux")]
2312 #[test]
2313 fn probing_io_max_creates_nothing_that_survives_the_call() {
2314 if skip_without_host_support("probing_io_max_creates_nothing_that_survives_the_call") {
2315 return;
2316 }
2317 let temp = tempfile::tempdir().unwrap();
2318 let report = probe_io_max(&HostResourceLimits::default(), &temp.path().join("workdir"));
2319 assert!(
2320 matches!(
2321 report.status,
2322 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
2323 ),
2324 "a performed probe must report what it performed, got {report:?}"
2325 );
2326
2327 let leftovers: Vec<String> = std::fs::read_dir(delegated_base().expect("a delegated base"))
2331 .unwrap()
2332 .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
2333 .filter(|name| name.contains("explain-scope"))
2334 .collect();
2335 assert!(
2336 leftovers.is_empty(),
2337 "the throwaway cgroup outlived the probe: {leftovers:?}"
2338 );
2339 }
2340
2341 #[test]
2342 fn scope_is_required_by_any_route_to_a_native_subprocess() {
2343 let shell = CapabilityPolicy {
2344 shell_allow: vec!["bash".to_string()],
2345 ..CapabilityPolicy::default()
2346 };
2347 let spawn = CapabilityPolicy {
2348 spawn_allow: vec!["child".to_string()],
2349 ..CapabilityPolicy::default()
2350 };
2351 let bare = CapabilityPolicy::default();
2352
2353 assert!(requires_process_bounding(&shell, false));
2354 assert!(requires_process_bounding(&spawn, false));
2355 assert!(
2356 requires_process_bounding(&bare, true),
2357 "a native artifact alone is enough"
2358 );
2359 assert!(
2360 !requires_process_bounding(&bare, false),
2361 "a WASM-only capsule has no process tree to bound and must not be blocked"
2362 );
2363 }
2364
2365 #[test]
2369 fn prepare_scope_returns_none_when_bounding_is_not_required() {
2370 let temp = tempfile::tempdir().unwrap();
2371 let scope = prepare_scope(
2372 false,
2373 &HostResourceLimits::default(),
2374 "ses_test",
2375 temp.path(),
2376 )
2377 .expect("a capsule with no subprocess capability must never be refused");
2378 assert!(scope.scope.is_none());
2379 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
2381 assert!(
2382 scope.io_max.reason.is_some(),
2383 "not-required must say which cause"
2384 );
2385 assert_eq!(io_max_warning(&scope.io_max), None);
2386 }
2387
2388 #[cfg(not(target_os = "linux"))]
2391 #[test]
2392 fn prepare_scope_is_never_an_error_off_linux() {
2393 let temp = tempfile::tempdir().unwrap();
2394 let scope = prepare_scope(
2395 true,
2396 &HostResourceLimits::default(),
2397 "ses_test",
2398 temp.path(),
2399 )
2400 .expect("a non-Linux host must not refuse to launch for a missing cgroup");
2401 assert!(scope.scope.is_none());
2402 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
2403 assert!(scope
2404 .io_max
2405 .reason
2406 .as_deref()
2407 .expect("not-required off Linux must say cgroups cannot exist here")
2408 .contains("Linux"));
2409 }
2410
2411 #[cfg(target_os = "linux")]
2416 #[test]
2417 #[allow(clippy::print_stdout)]
2418 fn a_required_scope_is_created_whatever_cgroup_this_process_inherited() {
2419 if skip_without_host_support(
2420 "a_required_scope_is_created_whatever_cgroup_this_process_inherited",
2421 ) {
2422 return;
2423 }
2424 let temp = tempfile::tempdir().unwrap();
2425 let prepared = prepare_scope(
2426 true,
2427 &HostResourceLimits::default(),
2428 "ses_selftest",
2429 temp.path(),
2430 )
2431 .expect("a Linux host with a systemd user session must be able to bound a subprocess tree");
2432 let io_max = prepared.io_max.clone();
2433 let scope = prepared
2434 .scope
2435 .expect("a required scope on Linux is never `None`");
2436
2437 for file in ["memory.max", "pids.max", "cpu.max"] {
2438 let contents = std::fs::read_to_string(scope.path().join(file))
2439 .unwrap_or_else(|error| panic!("{file} unreadable in the scope: {error}"));
2440 assert!(!contents.trim().is_empty(), "{file} was left unset");
2441 assert_ne!(contents.trim(), "max", "{file} carries no ceiling");
2442 }
2443 match std::fs::read_to_string(scope.path().join("memory.swap.max")) {
2444 Ok(contents) => {
2445 assert_eq!(
2446 contents.trim(),
2447 SCOPE_SWAP_MAX,
2448 "memory.swap.max was not bounded"
2449 );
2450 assert_eq!(contents.trim(), "0");
2451 println!(
2452 "memory.swap.max in the created scope reads {}",
2453 contents.trim()
2454 );
2455 }
2456 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2457 assert_eq!(probe_swap_control(), SwapControl::Absent);
2458 println!("this kernel exposes no memory.swap.max; swap is not bounded here");
2459 }
2460 Err(error) => panic!("memory.swap.max unreadable in the scope: {error}"),
2461 }
2462
2463 assert!(
2466 matches!(
2467 io_max.status,
2468 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
2469 ),
2470 "a created scope must report a performed io.max write, got {io_max:?}"
2471 );
2472 assert_eq!(
2473 io_max.declared_bytes_per_sec,
2474 HostResourceLimits::default().cgroup_io_bytes_per_sec
2475 );
2476 if io_max.status == IoMaxStatus::Unavailable {
2477 assert!(io_max.reason.is_some(), "unavailable must carry a reason");
2478 assert!(io_max_warning(&io_max).is_some());
2479 } else {
2480 assert_eq!(io_max.reason, None);
2481 assert_eq!(io_max_warning(&io_max), None);
2482 }
2483 }
2484
2485 #[cfg(target_os = "linux")]
2489 #[test]
2490 #[allow(clippy::print_stdout)]
2491 fn killing_a_scope_without_cgroup_kill_empties_it() {
2492 if skip_without_host_support("killing_a_scope_without_cgroup_kill_empties_it") {
2493 return;
2494 }
2495 let temp = tempfile::tempdir().unwrap();
2496 let scope = prepare_scope(
2497 true,
2498 &HostResourceLimits::default(),
2499 "ses_kill_fallback",
2500 temp.path(),
2501 )
2502 .expect("a delegated scope")
2503 .scope
2504 .expect("a required scope on Linux is never None");
2505 let enforcement = crate::sandbox::ShellEnforcement {
2506 cgroup_scope: Some(Arc::clone(&scope)),
2507 ..crate::sandbox::ShellEnforcement::environment_only()
2508 };
2509 let mut command = std::process::Command::new("bash");
2510 command
2511 .args([
2512 "-c",
2513 "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
2514 ])
2515 .stdout(std::process::Stdio::null())
2516 .stderr(std::process::Stdio::null());
2517 crate::sandbox::attach_process_limits(&mut command, &enforcement);
2518 let mut child = command.spawn().expect("bash spawns into the scope");
2519
2520 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2521 while scope.member_pids().len() < 3 {
2522 assert!(
2523 std::time::Instant::now() < deadline,
2524 "the scope never held bash and both sleepers: {:?}",
2525 scope.member_pids()
2526 );
2527 std::thread::sleep(std::time::Duration::from_millis(20));
2528 }
2529 let members = scope.member_pids();
2530
2531 FORCE_KILL_FALLBACK.with(|flag| flag.set(true));
2532 let killed = scope.kill_all();
2533 FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
2534 let emptied = scope.wait_until_empty(std::time::Duration::from_secs(2));
2535 let status = child.wait().expect("bash is reaped");
2536 println!(
2537 "members before: {members:?}; fallback kill: {killed:?}; empty: {emptied}; \
2538 cgroup.procs after: {:?}; bash: {status}",
2539 scope.member_pids()
2540 );
2541 killed.expect("the fallback kill succeeds");
2542 assert!(emptied, "the scope still holds {:?}", scope.member_pids());
2543 assert_eq!(
2544 std::os::unix::process::ExitStatusExt::signal(&status),
2545 Some(libc::SIGKILL)
2546 );
2547 }
2548
2549 #[cfg(target_os = "linux")]
2552 fn scope_with_sleepers(
2553 session: &str,
2554 fallback: bool,
2555 ) -> (Arc<CgroupScope>, std::process::Child) {
2556 let temp = tempfile::tempdir().unwrap();
2557 FORCE_KILL_FALLBACK.with(|flag| flag.set(fallback));
2558 let scope = prepare_scope(true, &HostResourceLimits::default(), session, temp.path());
2559 FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
2560 let scope = scope
2561 .expect("a delegated scope")
2562 .scope
2563 .expect("a required scope on Linux is never None");
2564 let enforcement = crate::sandbox::ShellEnforcement {
2565 cgroup_scope: Some(Arc::clone(&scope)),
2566 ..crate::sandbox::ShellEnforcement::environment_only()
2567 };
2568 let mut command = std::process::Command::new("bash");
2569 command
2570 .args([
2571 "-c",
2572 "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
2573 ])
2574 .stdout(std::process::Stdio::null())
2575 .stderr(std::process::Stdio::null());
2576 crate::sandbox::attach_process_limits(&mut command, &enforcement);
2577 let child = command.spawn().expect("bash spawns into the scope");
2578 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2579 while scope.member_pids().len() < 3 {
2580 assert!(
2581 std::time::Instant::now() < deadline,
2582 "the scope never held bash and both sleepers: {:?}",
2583 scope.member_pids()
2584 );
2585 std::thread::sleep(std::time::Duration::from_millis(20));
2586 }
2587 (scope, child)
2588 }
2589
2590 #[cfg(target_os = "linux")]
2591 fn watcher_of(scope: &CgroupScope) -> &ScopeWatcher {
2592 scope
2593 .watcher
2594 .as_ref()
2595 .expect("a scope on Linux has a watcher")
2596 }
2597
2598 #[cfg(target_os = "linux")]
2601 fn assert_a_cut_lifeline_empties_the_scope(test_name: &str, fallback: bool) {
2602 if skip_without_host_support(test_name) {
2603 return;
2604 }
2605 let (scope, mut child) = scope_with_sleepers(test_name, fallback);
2606 let members = scope.member_pids();
2607 watcher_of(&scope).cut_lifeline();
2608 assert!(
2609 scope.wait_until_empty(std::time::Duration::from_secs(5)),
2610 "the watcher left {:?} of {members:?} in the scope",
2611 scope.member_pids()
2612 );
2613 for pid in members {
2614 assert!(
2615 !Path::new(&format!("/proc/{pid}")).exists()
2616 || std::fs::read_to_string(format!("/proc/{pid}/stat"))
2617 .is_ok_and(|stat| stat.contains(") Z ")),
2618 "{pid} is still running"
2619 );
2620 }
2621 let status = child.wait().expect("bash is reaped");
2622 assert_eq!(
2623 std::os::unix::process::ExitStatusExt::signal(&status),
2624 Some(libc::SIGKILL)
2625 );
2626 }
2627
2628 #[cfg(target_os = "linux")]
2629 #[test]
2630 fn scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes() {
2631 assert_a_cut_lifeline_empties_the_scope(
2632 "scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes",
2633 false,
2634 );
2635 }
2636
2637 #[cfg(target_os = "linux")]
2638 #[test]
2639 fn scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes() {
2640 assert_a_cut_lifeline_empties_the_scope(
2641 "scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes",
2642 true,
2643 );
2644 }
2645
2646 #[cfg(target_os = "linux")]
2650 #[test]
2651 #[allow(unsafe_code)]
2652 fn scope_watcher_holds_only_its_own_descriptors_outside_the_scope() {
2653 if skip_without_host_support(
2654 "scope_watcher_holds_only_its_own_descriptors_outside_the_scope",
2655 ) {
2656 return;
2657 }
2658 let own = tempfile::tempfile().unwrap();
2660 let own_target = std::fs::read_link(format!(
2661 "/proc/self/fd/{}",
2662 std::os::fd::AsRawFd::as_raw_fd(&own)
2663 ))
2664 .unwrap();
2665 let (scope, mut child) = scope_with_sleepers("ses_watcher_fds", false);
2666 let pid = watcher_of(&scope).pid;
2667
2668 let fd_dir = format!("/proc/{pid}/fd");
2669 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2670 let targets = loop {
2671 let targets: Vec<(String, PathBuf)> = std::fs::read_dir(&fd_dir)
2672 .unwrap()
2673 .filter_map(Result::ok)
2674 .filter_map(|entry| {
2675 let target = std::fs::read_link(entry.path()).ok()?;
2676 Some((entry.file_name().to_string_lossy().into_owned(), target))
2677 })
2678 .collect();
2679 let settled = targets.iter().all(|(_, target)| {
2680 target == Path::new("/dev/null")
2681 || target.to_string_lossy().starts_with("pipe:")
2682 || target.starts_with(scope.path())
2683 });
2684 if settled || std::time::Instant::now() >= deadline {
2685 break targets;
2686 }
2687 std::thread::sleep(std::time::Duration::from_millis(20));
2688 };
2689 let mut numbers: Vec<u32> = targets
2690 .iter()
2691 .map(|(name, _)| name.parse().unwrap())
2692 .collect();
2693 numbers.sort_unstable();
2694 assert!(numbers.starts_with(&[0, 1, 2]), "{targets:?}");
2695 for (name, target) in &targets {
2696 assert_ne!(target, &own_target, "fd {name}");
2697 if ["0", "1", "2"].contains(&name.as_str()) {
2698 assert_eq!(target, Path::new("/dev/null"), "fd {name}");
2699 } else {
2700 assert!(
2701 target.to_string_lossy().starts_with("pipe:")
2702 || target.starts_with(scope.path()),
2703 "fd {name} -> {}",
2704 target.display()
2705 );
2706 }
2707 }
2708 let pipes = targets
2709 .iter()
2710 .filter(|(_, target)| target.to_string_lossy().starts_with("pipe:"))
2711 .count();
2712 assert_eq!(pipes, 1, "the lifeline is its only pipe: {targets:?}");
2713
2714 assert!(!scope.member_pids().contains(&pid));
2715 assert_eq!(
2717 unsafe { libc::getpgid(pid) },
2718 pid,
2719 "a process group of its own"
2720 );
2721
2722 scope.kill_all().unwrap();
2723 child.wait().unwrap();
2724 }
2725
2726 #[cfg(target_os = "linux")]
2728 #[test]
2729 #[allow(unsafe_code)]
2730 fn scope_watcher_is_reaped_when_the_scope_drops() {
2731 if skip_without_host_support("scope_watcher_is_reaped_when_the_scope_drops") {
2732 return;
2733 }
2734 let (scope, mut child) = scope_with_sleepers("ses_watcher_reaped", false);
2735 let pid = watcher_of(&scope).pid;
2736 let path = scope.path().to_path_buf();
2737 drop(Arc::try_unwrap(scope).expect("the test holds the only reference"));
2738 child.wait().unwrap();
2739 let mut status = 0;
2740 let waited = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
2742 assert_eq!(waited, -1);
2743 assert_eq!(
2744 std::io::Error::last_os_error().raw_os_error(),
2745 Some(libc::ECHILD)
2746 );
2747 assert!(!path.exists(), "the scope directory is removed");
2748 }
2749
2750 #[test]
2753 fn the_scope_limits_bound_swap_at_zero() {
2754 let declared = HostResourceLimits {
2755 cgroup_memory_bytes: 268_435_456,
2756 ..HostResourceLimits::default()
2757 };
2758 for limits in [HostResourceLimits::default(), declared] {
2759 let writes: Vec<(&str, String)> = scope_limits(&limits)
2760 .into_iter()
2761 .map(|limit| (limit.file, limit.value))
2762 .collect();
2763 assert!(
2764 writes.contains(&("memory.swap.max", "0".to_string())),
2765 "writes were {writes:?}"
2766 );
2767 assert!(writes.contains(&("memory.max", limits.cgroup_memory_bytes.to_string())));
2768 assert_eq!(
2769 writes.iter().map(|(file, _)| *file).collect::<Vec<_>>(),
2770 ["memory.max", "memory.swap.max", "pids.max", "cpu.max"]
2771 );
2772 }
2773 let absent_ok: Vec<&str> = scope_limits(&HostResourceLimits::default())
2774 .into_iter()
2775 .filter(|limit| limit.absent_ok)
2776 .map(|limit| limit.file)
2777 .collect();
2778 assert_eq!(absent_ok, ["memory.swap.max"]);
2779 }
2780
2781 #[test]
2782 fn active_swap_areas_are_the_lines_below_the_header() {
2783 let header = "Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n";
2784 assert_eq!(count_swap_areas(header), 0);
2785 assert_eq!(count_swap_areas(""), 0);
2786 assert_eq!(
2787 count_swap_areas(&format!(
2788 "{header}/swapfile file\t\t33554428\t\t0\t\t-2\n"
2789 )),
2790 1
2791 );
2792 }
2793
2794 #[cfg(target_os = "linux")]
2797 #[test]
2798 fn transient_scope_unit_names_are_murmur_prefixed_and_unique() {
2799 let first = transient_scope_unit_name();
2800 let second = transient_scope_unit_name();
2801 assert!(first.starts_with(&format!("murmur-{}-", std::process::id())));
2802 assert!(first.ends_with(".scope"));
2803 assert_ne!(first, second);
2804 }
2805
2806 #[test]
2807 fn unified_cgroup_path_is_read_from_the_zero_hierarchy_line() {
2808 let v2_only = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope\n";
2809 assert_eq!(
2810 parse_unified_cgroup_path(v2_only).as_deref(),
2811 Some("/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope")
2812 );
2813
2814 let hybrid = "12:pids:/user.slice\n11:memory:/user.slice\n0::/user.slice/session.scope\n";
2815 assert_eq!(
2816 parse_unified_cgroup_path(hybrid).as_deref(),
2817 Some("/user.slice/session.scope")
2818 );
2819
2820 let v1_only = "12:pids:/user.slice\n11:memory:/user.slice\n";
2821 assert_eq!(parse_unified_cgroup_path(v1_only), None);
2822 }
2823
2824 #[test]
2825 fn cgroup2_mount_falls_back_to_the_conventional_path() {
2826 let mounts = "cgroup2 /custom/cgroup cgroup2 rw,nsdelegate 0 0\n";
2827 assert_eq!(parse_cgroup2_mount(mounts), "/custom/cgroup");
2828 assert_eq!(
2829 parse_cgroup2_mount("proc /proc proc rw 0 0\n"),
2830 "/sys/fs/cgroup"
2831 );
2832 }
2833
2834 #[test]
2835 fn missing_controllers_reports_only_what_is_absent() {
2836 assert_eq!(
2837 missing_controllers("cpu memory", REQUIRED_CONTROLLERS),
2838 vec!["pids".to_string()]
2839 );
2840 assert!(missing_controllers("cpu io memory pids", REQUIRED_CONTROLLERS).is_empty());
2841 assert_eq!(
2842 missing_controllers("", REQUIRED_CONTROLLERS),
2843 vec!["memory".to_string(), "pids".to_string(), "cpu".to_string()]
2844 );
2845 }
2846
2847 #[test]
2848 fn event_counters_parse_the_named_key_only() {
2849 let memory_events = "low 0\nhigh 0\nmax 12\noom 3\noom_kill 2\n";
2850 assert_eq!(parse_event_counter(memory_events, "oom_kill"), 2);
2851 assert_eq!(parse_event_counter(memory_events, "max"), 12);
2852 assert_eq!(parse_event_counter("max 7\n", "max"), 7);
2853 assert_eq!(parse_event_counter("", "oom_kill"), 0);
2854 assert_eq!(parse_event_counter("oom_kill\n", "oom_kill"), 0);
2855 }
2856
2857 #[test]
2860 fn attribution_uses_the_delta_across_one_call() {
2861 let before = CgroupEventCounters {
2862 oom_kill: 1,
2863 pids_max: 4,
2864 };
2865 assert_eq!(before.attribution_since(before), None);
2866 assert_eq!(
2867 CgroupEventCounters {
2868 oom_kill: 2,
2869 pids_max: 4
2870 }
2871 .attribution_since(before),
2872 Some("cgroup_memory_bytes")
2873 );
2874 assert_eq!(
2875 CgroupEventCounters {
2876 oom_kill: 1,
2877 pids_max: 5
2878 }
2879 .attribution_since(before),
2880 Some("cgroup_pids_max")
2881 );
2882 }
2883
2884 fn scope_without_kernel() -> CgroupScope {
2887 CgroupScope {
2888 path: PathBuf::new(),
2889 procs_fd: None,
2890 occupancy: Arc::default(),
2891 #[cfg(target_os = "linux")]
2892 watcher: None,
2893 }
2894 }
2895
2896 #[test]
2897 fn occupancy_alone_is_exclusive() {
2898 let scope = scope_without_kernel();
2899 let call = scope.enter();
2900 assert!(call.exclusive());
2901 }
2902
2903 #[test]
2904 fn occupancy_entering_beside_another_is_not_exclusive() {
2905 let scope = scope_without_kernel();
2906 let first = scope.enter();
2907 let second = scope.enter();
2908 assert!(!second.exclusive(), "someone was already inside");
2909 drop(first);
2910 assert!(
2911 !second.exclusive(),
2912 "the sibling leaving does not clean the window it shared"
2913 );
2914 }
2915
2916 #[test]
2917 fn occupancy_someone_entering_during_the_window_makes_it_shared() {
2918 let scope = scope_without_kernel();
2919 let first = scope.enter();
2920 assert!(first.exclusive());
2921 drop(scope.enter());
2922 assert!(
2923 !first.exclusive(),
2924 "a call that entered and left inside this window could have moved the counters"
2925 );
2926 }
2927
2928 #[test]
2929 fn occupancy_drop_releases_the_scope() {
2930 let scope = scope_without_kernel();
2931 drop(scope.enter());
2932 let next = scope.enter();
2933 assert!(
2934 next.exclusive(),
2935 "an earlier call that has left does not share this window"
2936 );
2937 }
2938
2939 #[test]
2942 fn occupancy_guard_outlives_its_borrow_on_another_thread() {
2943 let scope = scope_without_kernel();
2944 let demoted = scope.enter();
2945 let held = std::thread::spawn(move || demoted);
2946 let demoted = held.join().unwrap();
2947 let foreground = scope.enter();
2948 assert!(!foreground.exclusive());
2949 assert!(!demoted.exclusive());
2950 }
2951
2952 #[test]
2953 fn scope_dir_name_is_sanitized_and_never_empty() {
2954 assert!(scope_dir_name("ses_abc123").starts_with("murmur-ses_abc123-"));
2955 assert!(scope_dir_name("../escape").starts_with("murmur-.._escape-"));
2956 assert!(!scope_dir_name("../escape").contains('/'));
2957 assert!(scope_dir_name("").starts_with("murmur-"));
2958 }
2959
2960 #[test]
2964 fn delegated_base_ascends_out_of_inherited_supervisor_leaves() {
2965 let base = PathBuf::from("/sys/fs/cgroup/user.slice/app.slice/mur.scope");
2966
2967 assert_eq!(ascend_out_of_supervisor_leaves(base.clone()), base);
2968 assert_eq!(
2969 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF)),
2970 base
2971 );
2972 assert_eq!(
2973 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF).join(SUPERVISOR_LEAF)),
2974 base,
2975 "a base already nested by an earlier build must still resolve to the real root"
2976 );
2977 }
2978
2979 #[test]
2983 fn scope_dir_name_is_unique_per_call_for_one_id() {
2984 let first = scope_dir_name("p");
2985 let second = scope_dir_name("p");
2986 assert_ne!(
2987 first, second,
2988 "same plan id must not reuse a scope directory"
2989 );
2990 }
2991
2992 #[test]
2993 fn pid_is_formatted_without_allocating() {
2994 let mut buf = [0u8; 24];
2995 let len = format_pid(4321, &mut buf);
2996 assert_eq!(&buf[..len], b"4321\n");
2997
2998 let len = format_pid(7, &mut buf);
2999 assert_eq!(&buf[..len], b"7\n");
3000 }
3001
3002 #[test]
3003 fn device_numbers_use_the_wide_glibc_encoding() {
3004 assert_eq!(device_major_minor(0x800), (8, 0));
3006 assert_eq!(device_major_minor((259 << 8) | 3), (259, 3));
3008 }
3009}