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))]
118pub(crate) fn scope_limits(limits: &HostResourceLimits) -> Vec<ScopeLimit> {
119 let required = |file, value| ScopeLimit {
120 file,
121 value,
122 absent_ok: false,
123 };
124 let mut writes = vec![
125 required("memory.max", limits.cgroup_memory_bytes.to_string()),
126 ScopeLimit {
127 file: "memory.swap.max",
128 value: SCOPE_SWAP_MAX.to_string(),
129 absent_ok: true,
130 },
131 required("pids.max", limits.cgroup_pids_max.to_string()),
132 ];
133 if let Some(percent) = limits.cgroup_cpu_percent {
134 writes.push(required(
135 "cpu.max",
136 format!(
137 "{} {CPU_MAX_PERIOD_US}",
138 u64::from(percent) * (CPU_MAX_PERIOD_US / 100)
139 ),
140 ));
141 }
142 writes
143}
144
145#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
149pub(crate) fn declared_scope_limits(
150 declared: &murmur_artifact::ResourceCapabilities,
151) -> Vec<ScopeLimit> {
152 let required = |file, value| ScopeLimit {
153 file,
154 value,
155 absent_ok: false,
156 };
157 let mut writes = Vec::new();
158 if let Some(bytes) = declared.cgroup_memory_bytes {
159 writes.push(required("memory.max", bytes.to_string()));
160 }
161 if let Some(pids) = declared.cgroup_pids_max {
162 writes.push(required("pids.max", pids.to_string()));
163 }
164 if let Some(percent) = declared.cgroup_cpu_percent {
165 writes.push(required(
166 "cpu.max",
167 format!(
168 "{} {CPU_MAX_PERIOD_US}",
169 u64::from(percent) * (CPU_MAX_PERIOD_US / 100)
170 ),
171 ));
172 }
173 writes
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum SwapControl {
179 Exposed,
182 Absent,
185 Unknown,
188}
189
190#[must_use]
197pub fn probe_swap_control() -> SwapControl {
198 #[cfg(not(target_os = "linux"))]
199 {
200 SwapControl::Unknown
201 }
202
203 #[cfg(target_os = "linux")]
204 {
205 let mount = std::fs::read_to_string("/proc/mounts")
206 .map(|contents| parse_cgroup2_mount(&contents))
207 .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
208 let Some(own) = std::fs::read_to_string("/proc/self/cgroup")
209 .ok()
210 .and_then(|contents| parse_unified_cgroup_path(&contents))
211 else {
212 return SwapControl::Unknown;
213 };
214 let root = PathBuf::from(mount);
215 let mut dir = root.join(own.trim_start_matches('/'));
216 loop {
217 if dir.join("memory.max").exists() {
218 return if dir.join("memory.swap.max").exists() {
219 SwapControl::Exposed
220 } else {
221 SwapControl::Absent
222 };
223 }
224 if dir == root || !dir.pop() || !dir.starts_with(&root) {
225 return SwapControl::Unknown;
226 }
227 }
228 }
229}
230
231#[must_use]
233pub fn active_swap_areas() -> Option<usize> {
234 let contents = std::fs::read_to_string("/proc/swaps").ok()?;
235 Some(count_swap_areas(&contents))
236}
237
238fn count_swap_areas(contents: &str) -> usize {
240 contents
241 .lines()
242 .skip(1)
243 .filter(|line| !line.trim().is_empty())
244 .count()
245}
246
247#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
250const REQUIRED_CONTROLLERS: &[&str] = &["memory", "pids", "cpu"];
251
252#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
257const SUPERVISOR_LEAF: &str = "murmur-supervisor";
258
259#[derive(Debug)]
266pub(crate) struct CgroupScope {
267 path: PathBuf,
270 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
275 procs_fd: Option<std::os::fd::OwnedFd>,
276 occupancy: Arc<Occupancy>,
280 #[cfg(target_os = "linux")]
283 watcher: Option<ScopeWatcher>,
284}
285
286#[derive(Debug, Default)]
292struct Occupancy {
293 occupants: AtomicU64,
295 entries: AtomicU64,
298}
299
300impl Occupancy {
301 fn enter(self: &Arc<Self>) -> ScopeOccupancy {
303 let entries_before = self.entries.load(Ordering::SeqCst);
308 let others_inside = self.occupants.fetch_add(1, Ordering::SeqCst) > 0;
309 let sequence = self.entries.fetch_add(1, Ordering::SeqCst);
310 ScopeOccupancy {
311 occupancy: Arc::clone(self),
312 alone_at_entry: !others_inside && sequence == entries_before,
313 sequence,
314 }
315 }
316}
317
318#[derive(Debug)]
323pub(crate) struct ScopeOccupancy {
324 occupancy: Arc<Occupancy>,
325 alone_at_entry: bool,
326 sequence: u64,
327}
328
329impl ScopeOccupancy {
330 pub(crate) fn exclusive(&self) -> bool {
333 self.alone_at_entry && self.occupancy.entries.load(Ordering::SeqCst) == self.sequence + 1
334 }
335}
336
337impl Drop for ScopeOccupancy {
338 fn drop(&mut self) {
339 self.occupancy.occupants.fetch_sub(1, Ordering::SeqCst);
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
348pub(crate) struct CgroupEventCounters {
349 pub(crate) oom_kill: u64,
351 pub(crate) pids_max: u64,
353}
354
355impl CgroupEventCounters {
356 pub(crate) fn attribution_since(&self, before: Self) -> Option<&'static str> {
368 if self.oom_kill > before.oom_kill {
369 return Some("cgroup_memory_bytes");
370 }
371 if self.pids_max > before.pids_max {
372 return Some("cgroup_pids_max");
373 }
374 None
375 }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
386#[serde(rename_all = "kebab-case")]
387pub enum IoMaxStatus {
388 Enforced,
391 Unavailable,
395 NotRequired,
399 #[default]
402 NotProbed,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
412pub struct IoMaxReport {
413 pub declared_bytes_per_sec: u64,
417 pub status: IoMaxStatus,
419 #[serde(skip_serializing_if = "Option::is_none")]
422 pub reason: Option<String>,
423}
424
425impl IoMaxReport {
426 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
428 fn enforced(declared_bytes_per_sec: u64) -> Self {
429 Self {
430 declared_bytes_per_sec,
431 status: IoMaxStatus::Enforced,
432 reason: None,
433 }
434 }
435
436 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
438 fn unavailable(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
439 Self {
440 declared_bytes_per_sec,
441 status: IoMaxStatus::Unavailable,
442 reason: Some(reason.into()),
443 }
444 }
445
446 #[must_use]
452 pub fn no_scope_required(declared_bytes_per_sec: u64) -> Self {
453 Self::not_required(declared_bytes_per_sec, NO_SCOPE_REASON)
454 }
455
456 fn not_required(declared_bytes_per_sec: u64, reason: impl Into<String>) -> Self {
460 Self {
461 declared_bytes_per_sec,
462 status: IoMaxStatus::NotRequired,
463 reason: Some(reason.into()),
464 }
465 }
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
474#[serde(rename_all = "kebab-case")]
475pub enum CgroupScopeStatus {
476 Delegated,
479 Unavailable,
482 NotRequired,
485 #[default]
487 NotProbed,
488}
489
490#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
497pub struct CgroupScopeReport {
498 pub status: CgroupScopeStatus,
500 #[serde(skip_serializing_if = "Option::is_none")]
503 pub errno: Option<String>,
504 #[serde(skip_serializing_if = "Option::is_none")]
508 pub reason: Option<String>,
509}
510
511impl CgroupScopeReport {
512 fn delegated() -> Self {
514 Self {
515 status: CgroupScopeStatus::Delegated,
516 errno: None,
517 reason: None,
518 }
519 }
520
521 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
523 fn unavailable(error: &CgroupError) -> Self {
524 Self {
525 status: CgroupScopeStatus::Unavailable,
526 errno: error.errno.map(crate::network_namespace::errno_label),
527 reason: Some(error.message.clone()),
528 }
529 }
530
531 fn not_required(reason: &str) -> Self {
533 Self {
534 status: CgroupScopeStatus::NotRequired,
535 errno: None,
536 reason: Some(reason.to_string()),
537 }
538 }
539
540 #[must_use]
543 pub fn no_scope_required() -> Self {
544 Self::not_required(NO_CGROUP_SCOPE_REASON)
545 }
546
547 fn of_prepared(scope: Option<&Arc<CgroupScope>>, required: bool) -> Self {
550 match (scope, required) {
551 (Some(_), _) => Self::delegated(),
552 (None, false) => Self::no_scope_required(),
553 (None, true) => Self::not_required(NOT_LINUX_CGROUP_REASON),
554 }
555 }
556}
557
558#[must_use]
561pub fn render_cgroup_scope(report: &CgroupScopeReport) -> String {
562 let status = match report.status {
563 CgroupScopeStatus::Delegated => "delegated",
564 CgroupScopeStatus::Unavailable => "unavailable",
565 CgroupScopeStatus::NotRequired => "not-required",
566 CgroupScopeStatus::NotProbed => "not-probed",
567 };
568 match (&report.errno, &report.reason) {
569 (Some(errno), Some(reason)) => {
570 format!("cgroup scope: {status} ({errno}) \u{2014} {reason}")
571 }
572 (Some(errno), None) => format!("cgroup scope: {status} ({errno})"),
573 (None, Some(reason)) => format!("cgroup scope: {status} \u{2014} {reason}"),
574 (None, None) => format!("cgroup scope: {status}"),
575 }
576}
577
578#[derive(Debug, Clone, PartialEq, Eq)]
583pub(crate) struct CgroupError {
584 pub(crate) message: String,
586 pub(crate) errno: Option<i32>,
588}
589
590impl CgroupError {
591 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
593 fn io(message: String, error: &std::io::Error) -> Self {
594 Self {
595 message,
596 errno: error.raw_os_error(),
597 }
598 }
599}
600
601impl std::fmt::Display for CgroupError {
602 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603 f.write_str(&self.message)
604 }
605}
606
607impl From<String> for CgroupError {
608 fn from(message: String) -> Self {
609 Self {
610 message,
611 errno: None,
612 }
613 }
614}
615
616impl From<CgroupError> for String {
617 fn from(error: CgroupError) -> Self {
618 error.message
619 }
620}
621
622const NO_CGROUP_SCOPE_REASON: &str = "this capsule declares no way to run a subprocess, so \
625 a launch creates no cgroup scope for it";
626const NOT_LINUX_CGROUP_REASON: &str =
627 "cgroups are a Linux mechanism and this host has none, so a launch creates no cgroup scope";
628
629const NO_SCOPE_REASON: &str = "this capsule declares no way to run a subprocess, so it is \
632 given no cgroup scope and there is no process tree for an I/O ceiling to apply to";
633const UNCONFINED_NO_CGROUP_SCOPE_REASON: &str = "this session runs unconfined and \
636 capabilities.resources declares no cgroup field, so a launch creates no cgroup scope for it";
637const UNCONFINED_NO_SCOPE_REASON: &str = "this session runs unconfined and \
638 capabilities.resources declares no cgroup field, so it is given no cgroup scope and no \
639 I/O ceiling applies";
640
641#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
643const UNCONFINED_IO_UNDECLARED_REASON: &str = "this session runs unconfined and \
644 capabilities.resources declares no cgroup_io_bytes_per_sec, so its cgroup scope writes no \
645 io.max";
646#[cfg_attr(target_os = "linux", allow(dead_code))]
647const NOT_LINUX_REASON: &str =
648 "cgroups are a Linux mechanism and this host has none, so no io.max ceiling can exist here";
649
650const IO_MAX_UNAVAILABLE_WARNING: &str = "the declared \
652capabilities.resources.cgroup_io_bytes_per_sec ceiling did not apply to this session's cgroup \
653scope, so this capsule's subprocess tree has no I/O bandwidth bound";
654
655pub(crate) fn io_max_warning(
664 report: &IoMaxReport,
665 cpu_capped: bool,
666) -> Option<(&'static str, String)> {
667 if report.status != IoMaxStatus::Unavailable {
668 return None;
669 }
670 let declared = report.declared_bytes_per_sec;
671 let reason = report.reason.as_deref().unwrap_or("no reason reported");
672 let enforced = if cpu_capped {
673 "memory.max, pids.max and cpu.max"
674 } else {
675 "memory.max and pids.max"
676 };
677 Some((
678 murmur_artifact::W_SEC_021,
679 format!(
680 "{IO_MAX_UNAVAILABLE_WARNING}; {enforced} are still enforced on the scope \
681 (declared {declared} bytes/s): {reason}"
682 ),
683 ))
684}
685
686pub(crate) fn warn_for_unenforced_io_max(
688 workdir: &crate::workdir_fs::WorkdirRoot,
689 report: &IoMaxReport,
690 cpu_capped: bool,
691) {
692 if let Some((code, message)) = io_max_warning(report, cpu_capped) {
693 let link = murmur_artifact::security_warning_link(code);
694 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
695 crate::agent::append_bootstrap_log(
696 workdir,
697 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
698 );
699 }
700}
701
702#[derive(Debug)]
707pub(crate) struct PreparedScope {
708 pub(crate) scope: Option<Arc<CgroupScope>>,
710 pub(crate) io_max: IoMaxReport,
713 pub(crate) cgroup_scope: CgroupScopeReport,
715}
716
717pub(crate) fn prepare_scope(
732 required: bool,
733 limits: &HostResourceLimits,
734 session_id: &str,
735 workdir: &Path,
736) -> Result<PreparedScope, String> {
737 if !required {
738 return Ok(PreparedScope {
739 scope: None,
740 io_max: IoMaxReport::no_scope_required(limits.cgroup_io_bytes_per_sec),
741 cgroup_scope: CgroupScopeReport::of_prepared(None, false),
742 });
743 }
744 #[cfg(target_os = "linux")]
745 {
746 #[cfg(test)]
747 if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
748 return Err("forced: delegation refused by the test seam".to_string());
749 }
750 let (scope, io_max) = CgroupScope::create(
751 &scope_limits(limits),
752 IoMaxWrite::Declared(limits.cgroup_io_bytes_per_sec),
753 session_id,
754 workdir,
755 )?;
756 let scope = Arc::new(scope);
757 Ok(PreparedScope {
758 cgroup_scope: CgroupScopeReport::of_prepared(Some(&scope), true),
759 scope: Some(scope),
760 io_max,
761 })
762 }
763 #[cfg(not(target_os = "linux"))]
764 {
765 let _ = (session_id, workdir);
766 Ok(PreparedScope {
767 scope: None,
768 io_max: IoMaxReport::not_required(limits.cgroup_io_bytes_per_sec, NOT_LINUX_REASON),
769 cgroup_scope: CgroupScopeReport::of_prepared(None, true),
770 })
771 }
772}
773
774pub(crate) fn prepare_policy_scope(
780 policy: &crate::types::CapabilityPolicy,
781 session_id: &str,
782 workdir: &Path,
783) -> Result<PreparedScope, String> {
784 let required = requires_process_bounding(policy);
785 if policy.containment_floor != murmur_artifact::ContainmentClass::Unconfined {
786 return prepare_scope(required, &policy.resources, session_id, workdir);
787 }
788 let reported_bytes_per_sec = policy.resources.cgroup_io_bytes_per_sec;
789 if !required {
790 let (io_max, cgroup_scope) = unrequired_scope_reports(policy);
791 return Ok(PreparedScope {
792 scope: None,
793 io_max,
794 cgroup_scope,
795 });
796 }
797 #[cfg(target_os = "linux")]
798 {
799 #[cfg(test)]
800 if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
801 return Err("forced: delegation refused by the test seam".to_string());
802 }
803 let declared = &policy.declared_resources;
804 let io = match declared.cgroup_io_bytes_per_sec {
805 Some(bytes_per_sec) => IoMaxWrite::Declared(bytes_per_sec),
806 None => IoMaxWrite::Undeclared(reported_bytes_per_sec),
807 };
808 let (scope, io_max) =
809 CgroupScope::create(&declared_scope_limits(declared), io, session_id, workdir)?;
810 let scope = Arc::new(scope);
811 Ok(PreparedScope {
812 cgroup_scope: CgroupScopeReport::of_prepared(Some(&scope), true),
813 scope: Some(scope),
814 io_max,
815 })
816 }
817 #[cfg(not(target_os = "linux"))]
818 {
819 let _ = (session_id, workdir);
820 Ok(PreparedScope {
821 scope: None,
822 io_max: IoMaxReport::not_required(reported_bytes_per_sec, NOT_LINUX_REASON),
823 cgroup_scope: CgroupScopeReport::of_prepared(None, true),
824 })
825 }
826}
827
828fn unrequired_scope_reports(
832 policy: &crate::types::CapabilityPolicy,
833) -> (IoMaxReport, CgroupScopeReport) {
834 let reported_bytes_per_sec = policy.resources.cgroup_io_bytes_per_sec;
835 let has_route = crate::runtime::has_subprocess_route_at_staging(policy);
836 if policy.containment_floor == murmur_artifact::ContainmentClass::Unconfined && has_route {
837 return (
838 IoMaxReport::not_required(reported_bytes_per_sec, UNCONFINED_NO_SCOPE_REASON),
839 CgroupScopeReport::not_required(UNCONFINED_NO_CGROUP_SCOPE_REASON),
840 );
841 }
842 (
843 IoMaxReport::no_scope_required(reported_bytes_per_sec),
844 CgroupScopeReport::no_scope_required(),
845 )
846}
847
848#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
850#[derive(Debug, Clone, Copy)]
851enum IoMaxWrite {
852 Declared(u64),
854 Undeclared(u64),
857}
858
859#[derive(Debug, Clone, PartialEq, Eq)]
862pub struct ScopeProbe {
863 pub cgroup_scope: CgroupScopeReport,
865 pub io_max: IoMaxReport,
867}
868
869#[must_use]
891pub fn probe_scope(required: bool, limits: &HostResourceLimits, workdir: &Path) -> ScopeProbe {
892 let declared = limits.cgroup_io_bytes_per_sec;
893 if !required {
894 return ScopeProbe {
895 cgroup_scope: CgroupScopeReport::no_scope_required(),
896 io_max: IoMaxReport::no_scope_required(declared),
897 };
898 }
899 #[cfg(target_os = "linux")]
900 {
901 let base = match delegated_base() {
902 Ok(base) => base,
903 Err(error) => {
904 return ScopeProbe {
905 cgroup_scope: CgroupScopeReport::unavailable(&error),
906 io_max: IoMaxReport::unavailable(
907 declared,
908 format!("no delegated cgroup base to write io.max into: {error}"),
909 ),
910 }
911 }
912 };
913 let path = base.join(scope_dir_name("explain-scope"));
914 if let Err(error) = create_scope_dir(&path) {
915 return ScopeProbe {
916 cgroup_scope: CgroupScopeReport::unavailable(&scope_dir_refusal(&path, &error)),
917 io_max: IoMaxReport::unavailable(
918 declared,
919 format!(
920 "could not create a throwaway cgroup at {}: {error}",
921 path.display()
922 ),
923 ),
924 };
925 }
926 let io_max = write_io_max(&path, declared, workdir);
927 let _ = std::fs::remove_dir(&path);
930 ScopeProbe {
931 cgroup_scope: CgroupScopeReport::delegated(),
932 io_max,
933 }
934 }
935 #[cfg(not(target_os = "linux"))]
936 {
937 let _ = workdir;
938 ScopeProbe {
939 cgroup_scope: CgroupScopeReport::not_required(NOT_LINUX_CGROUP_REASON),
940 io_max: IoMaxReport::not_required(declared, NOT_LINUX_REASON),
941 }
942 }
943}
944
945#[cfg(target_os = "linux")]
948fn create_scope_dir(path: &Path) -> std::io::Result<()> {
949 match std::fs::create_dir(path) {
950 Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => Err(error),
951 _ => Ok(()),
952 }
953}
954
955#[cfg(target_os = "linux")]
957fn scope_dir_refusal(path: &Path, error: &std::io::Error) -> CgroupError {
958 CgroupError::io(
959 format!(
960 "could not create cgroup scope at {}: {error}",
961 path.display()
962 ),
963 error,
964 )
965}
966
967#[must_use]
972pub fn explain_scope_probe(policy: &crate::types::CapabilityPolicy, workdir: &Path) -> ScopeProbe {
973 let required = requires_process_bounding(policy);
974 if policy.containment_floor != murmur_artifact::ContainmentClass::Unconfined {
975 return probe_scope(required, &policy.resources, workdir);
976 }
977 if !required {
978 let (io_max, cgroup_scope) = unrequired_scope_reports(policy);
979 return ScopeProbe {
980 cgroup_scope,
981 io_max,
982 };
983 }
984 let declared_io = policy.declared_resources.cgroup_io_bytes_per_sec;
985 let mut probe = probe_scope(
986 true,
987 &HostResourceLimits {
988 cgroup_io_bytes_per_sec: declared_io
989 .unwrap_or(policy.resources.cgroup_io_bytes_per_sec),
990 ..policy.resources
991 },
992 workdir,
993 );
994 if cfg!(target_os = "linux") && declared_io.is_none() {
995 probe.io_max = IoMaxReport::not_required(
996 policy.resources.cgroup_io_bytes_per_sec,
997 UNCONFINED_IO_UNDECLARED_REASON,
998 );
999 }
1000 probe
1001}
1002
1003pub fn requires_process_bounding(policy: &crate::types::CapabilityPolicy) -> bool {
1013 let has_route = crate::runtime::has_subprocess_route_at_staging(policy);
1014 if policy.containment_floor == murmur_artifact::ContainmentClass::Unconfined {
1015 return has_route && crate::resources::declares_cgroup_limit(&policy.declared_resources);
1016 }
1017 has_route
1018}
1019
1020impl CgroupScope {
1021 pub(crate) fn path(&self) -> &Path {
1023 &self.path
1024 }
1025
1026 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1034 #[allow(unsafe_code)]
1035 pub(crate) fn join_current_process(&self) -> std::io::Result<()> {
1036 use std::os::fd::AsRawFd;
1037
1038 let Some(fd) = self.procs_fd.as_ref() else {
1039 return Ok(());
1040 };
1041
1042 let pid = unsafe { libc::getpid() };
1044 let mut buf = [0u8; 24];
1045 let written = format_pid(pid, &mut buf);
1046
1047 let mut offset = 0;
1048 while offset < written {
1049 let rc = unsafe {
1052 libc::write(
1053 fd.as_raw_fd(),
1054 buf[offset..written].as_ptr().cast::<libc::c_void>(),
1055 written - offset,
1056 )
1057 };
1058 if rc < 0 {
1059 let error = std::io::Error::last_os_error();
1060 if error.kind() == std::io::ErrorKind::Interrupted {
1061 continue;
1062 }
1063 return Err(error);
1064 }
1065 offset += rc as usize;
1066 }
1067 Ok(())
1068 }
1069
1070 pub(crate) fn enter(&self) -> ScopeOccupancy {
1073 self.occupancy.enter()
1074 }
1075
1076 pub(crate) fn places_subprocesses(&self) -> bool {
1082 self.procs_fd.is_some()
1083 }
1084
1085 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1090 pub(crate) fn event_counters(&self) -> CgroupEventCounters {
1091 CgroupEventCounters {
1092 oom_kill: read_event_counter(&self.path.join("memory.events"), "oom_kill"),
1093 pids_max: read_event_counter(&self.path.join("pids.events"), "max"),
1094 }
1095 }
1096}
1097
1098impl CgroupScope {
1099 pub(crate) fn kill_all(&self) -> std::io::Result<()> {
1110 if self.path.as_os_str().is_empty() {
1111 return Ok(());
1112 }
1113 #[cfg(target_os = "linux")]
1114 {
1115 #[cfg(test)]
1116 let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
1117 #[cfg(not(test))]
1118 let forced = false;
1119 if !forced {
1120 match write_existing(&self.path.join("cgroup.kill"), "1") {
1121 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1122 written => return written,
1123 }
1124 }
1125 self.kill_by_freezing()
1126 }
1127 #[cfg(not(target_os = "linux"))]
1128 Ok(())
1129 }
1130
1131 pub(crate) fn wait_until_empty(&self, timeout: std::time::Duration) -> bool {
1136 let deadline = std::time::Instant::now() + timeout;
1137 loop {
1138 if self.member_pids().is_empty() {
1139 return true;
1140 }
1141 if std::time::Instant::now() >= deadline {
1142 return false;
1143 }
1144 std::thread::sleep(KILL_POLL_INTERVAL);
1145 }
1146 }
1147
1148 pub(crate) fn member_pids(&self) -> Vec<i32> {
1151 if self.path.as_os_str().is_empty() {
1152 return Vec::new();
1153 }
1154 std::fs::read_to_string(self.path.join("cgroup.procs"))
1155 .map(|procs| {
1156 procs
1157 .lines()
1158 .filter_map(|line| line.trim().parse().ok())
1159 .collect()
1160 })
1161 .unwrap_or_default()
1162 }
1163
1164 #[cfg(target_os = "linux")]
1170 #[allow(unsafe_code)]
1171 fn kill_by_freezing(&self) -> std::io::Result<()> {
1172 let freeze = self.path.join("cgroup.freeze");
1173 let deadline = std::time::Instant::now() + KILL_FALLBACK_DEADLINE;
1174 loop {
1175 let pids = self.member_pids();
1176 if pids.is_empty() {
1177 return Ok(());
1178 }
1179 let _ = write_existing(&freeze, "1");
1181 for pid in pids {
1182 unsafe { libc::kill(pid, libc::SIGKILL) };
1184 }
1185 let _ = write_existing(&freeze, "0");
1186 if std::time::Instant::now() >= deadline {
1187 return Err(std::io::Error::other(format!(
1188 "processes were still in {} after {} ms of SIGKILL",
1189 self.path.display(),
1190 KILL_FALLBACK_DEADLINE.as_millis()
1191 )));
1192 }
1193 std::thread::sleep(KILL_POLL_INTERVAL);
1194 }
1195 }
1196}
1197
1198const KILL_FALLBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
1200
1201const KILL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1203
1204#[cfg(target_os = "linux")]
1207fn write_existing(path: &Path, value: &str) -> std::io::Result<()> {
1208 use std::io::Write as _;
1209 let file = std::fs::OpenOptions::new().write(true).open(path)?;
1210 (&file).write_all(value.as_bytes())
1211}
1212
1213#[cfg(all(test, target_os = "linux"))]
1216thread_local! {
1217 pub(crate) static FORCE_KILL_FALLBACK: std::cell::Cell<bool> =
1218 const { std::cell::Cell::new(false) };
1219}
1220
1221#[cfg(all(test, target_os = "linux"))]
1224thread_local! {
1225 pub(crate) static FORCE_DELEGATION_FAILURE: std::cell::Cell<bool> =
1226 const { std::cell::Cell::new(false) };
1227}
1228
1229impl Drop for CgroupScope {
1230 fn drop(&mut self) {
1238 if self.path.as_os_str().is_empty() {
1239 return;
1240 }
1241 if !self.member_pids().is_empty() {
1242 let _ = self.kill_all();
1243 self.wait_until_empty(KILL_FALLBACK_DEADLINE);
1244 }
1245 self.procs_fd = None;
1248 #[cfg(target_os = "linux")]
1249 {
1250 self.watcher = None;
1251 }
1252 if let Err(error) = std::fs::remove_dir(&self.path) {
1253 crate::runtime_err!(
1254 "[capsule-runtime] note: could not remove cgroup scope {} ({error}); \
1255 it will be reclaimed once its last task exits",
1256 self.path.display()
1257 );
1258 }
1259 }
1260}
1261
1262#[cfg(target_os = "linux")]
1267#[derive(Debug)]
1268struct ScopeWatcher {
1269 lifeline: std::sync::Mutex<Option<std::os::fd::OwnedFd>>,
1273 pid: libc::pid_t,
1274}
1275
1276#[cfg(target_os = "linux")]
1280#[derive(Clone, Copy)]
1281struct WatcherKill {
1282 kill: libc::c_int,
1283 freeze: libc::c_int,
1284 procs: libc::c_int,
1285}
1286
1287#[cfg(target_os = "linux")]
1288impl ScopeWatcher {
1289 #[allow(unsafe_code)]
1293 fn spawn(scope: &Path) -> std::io::Result<Self> {
1294 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
1295
1296 #[cfg(test)]
1297 let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
1298 #[cfg(not(test))]
1299 let forced = false;
1300 let kill = if forced {
1301 None
1302 } else {
1303 open_if_present(&scope.join("cgroup.kill"), libc::O_WRONLY)?
1304 };
1305 let (freeze, procs) = match kill {
1306 Some(_) => (None, None),
1307 None => (
1308 open_if_present(&scope.join("cgroup.freeze"), libc::O_WRONLY)?,
1309 Some(open_with(&scope.join("cgroup.procs"), libc::O_RDONLY)?),
1310 ),
1311 };
1312 let devnull = open_with(Path::new("/dev/null"), libc::O_RDWR)?;
1313
1314 let mut ends = [0 as libc::c_int; 2];
1315 if unsafe { libc::pipe2(ends.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
1317 return Err(std::io::Error::last_os_error());
1318 }
1319 let (read_end, write_end) =
1322 unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
1323
1324 let raw = |fd: &Option<OwnedFd>| fd.as_ref().map_or(-1, AsRawFd::as_raw_fd);
1325 let plan = WatcherKill {
1326 kill: raw(&kill),
1327 freeze: raw(&freeze),
1328 procs: raw(&procs),
1329 };
1330 let mut keep = [-1 as libc::c_int; 7];
1334 let mut kept = 0;
1335 for fd in [
1336 0,
1337 1,
1338 2,
1339 read_end.as_raw_fd(),
1340 plan.kill,
1341 plan.freeze,
1342 plan.procs,
1343 ] {
1344 if fd >= 0 {
1345 keep[kept] = fd;
1346 kept += 1;
1347 }
1348 }
1349 keep[..kept].sort_unstable();
1350 let devnull_fd = devnull.as_raw_fd();
1351 let read_fd = read_end.as_raw_fd();
1352 let deadline_ns = KILL_FALLBACK_DEADLINE.as_nanos() as i64;
1353
1354 let pid = unsafe { libc::fork() };
1360 if pid < 0 {
1361 return Err(std::io::Error::last_os_error());
1362 }
1363 if pid == 0 {
1364 unsafe { watch(devnull_fd, &keep[..kept], read_fd, plan, deadline_ns) }
1366 }
1367 Ok(Self {
1368 lifeline: std::sync::Mutex::new(Some(write_end)),
1369 pid,
1370 })
1371 }
1372
1373 #[cfg(test)]
1375 fn cut_lifeline(&self) {
1376 self.lifeline
1377 .lock()
1378 .unwrap_or_else(std::sync::PoisonError::into_inner)
1379 .take();
1380 }
1381}
1382
1383#[cfg(target_os = "linux")]
1384impl Drop for ScopeWatcher {
1385 #[allow(unsafe_code)]
1388 fn drop(&mut self) {
1389 self.lifeline
1390 .get_mut()
1391 .unwrap_or_else(std::sync::PoisonError::into_inner)
1392 .take();
1393 let mut status = 0;
1394 loop {
1395 let waited = unsafe { libc::waitpid(self.pid, &mut status, 0) };
1397 if waited >= 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
1398 return;
1399 }
1400 }
1401 }
1402}
1403
1404#[cfg(target_os = "linux")]
1406fn open_with(path: &Path, flags: libc::c_int) -> std::io::Result<std::os::fd::OwnedFd> {
1407 use std::os::unix::fs::OpenOptionsExt;
1408
1409 std::fs::OpenOptions::new()
1410 .read(flags & libc::O_ACCMODE != libc::O_WRONLY)
1411 .write(flags & libc::O_ACCMODE != libc::O_RDONLY)
1412 .custom_flags(libc::O_CLOEXEC)
1413 .open(path)
1414 .map(std::os::fd::OwnedFd::from)
1415}
1416
1417#[cfg(target_os = "linux")]
1419fn open_if_present(
1420 path: &Path,
1421 flags: libc::c_int,
1422) -> std::io::Result<Option<std::os::fd::OwnedFd>> {
1423 match open_with(path, flags) {
1424 Ok(fd) => Ok(Some(fd)),
1425 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1426 Err(error) => Err(error),
1427 }
1428}
1429
1430#[cfg(target_os = "linux")]
1439#[allow(unsafe_code)]
1440unsafe fn watch(
1441 devnull: libc::c_int,
1442 keep: &[libc::c_int],
1443 read_fd: libc::c_int,
1444 plan: WatcherKill,
1445 deadline_ns: i64,
1446) -> ! {
1447 unsafe {
1450 for target in 0..3 {
1452 libc::dup2(devnull, target);
1453 }
1454 let mut low: libc::c_uint = 0;
1455 for &fd in keep {
1456 let fd = fd as libc::c_uint;
1457 if fd > low {
1458 close_range(low, fd - 1);
1459 }
1460 low = fd + 1;
1461 }
1462 close_range(low, libc::c_uint::MAX);
1463
1464 libc::setpgid(0, 0);
1465 let mut ignore: libc::sigaction = std::mem::zeroed();
1466 ignore.sa_sigaction = libc::SIG_IGN;
1467 for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGPIPE] {
1468 libc::sigaction(signal, &ignore, std::ptr::null_mut());
1469 }
1470
1471 let mut byte = [0u8; 1];
1472 loop {
1473 let read = libc::read(read_fd, byte.as_mut_ptr().cast(), 1);
1474 if read == 0 {
1475 break;
1476 }
1477 if read < 0 && *libc::__errno_location() != libc::EINTR {
1478 break;
1479 }
1480 }
1481
1482 kill_scope(plan, deadline_ns);
1483 libc::_exit(0)
1484 }
1485}
1486
1487#[cfg(target_os = "linux")]
1498#[allow(unsafe_code)]
1499pub(crate) unsafe fn close_range(low: libc::c_uint, high: libc::c_uint) {
1500 unsafe {
1502 if libc::syscall(libc::SYS_close_range, low, high, 0 as libc::c_uint) == 0 {
1503 return;
1504 }
1505 let mut limit: libc::rlimit = std::mem::zeroed();
1506 let ceiling = if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) == 0 {
1507 limit.rlim_cur.min(libc::c_uint::MAX as libc::rlim_t) as libc::c_uint
1508 } else {
1509 1 << 20
1510 };
1511 let mut fd = low;
1512 while fd <= high && fd < ceiling {
1513 libc::close(fd as libc::c_int);
1514 fd += 1;
1515 }
1516 }
1517}
1518
1519#[cfg(target_os = "linux")]
1526#[allow(unsafe_code)]
1527unsafe fn kill_scope(plan: WatcherKill, deadline_ns: i64) {
1528 unsafe {
1530 if plan.kill >= 0 {
1531 write_control(plan.kill, b"1");
1532 return;
1533 }
1534 let start = monotonic_ns();
1535 loop {
1536 if plan.freeze >= 0 {
1537 write_control(plan.freeze, b"1");
1538 }
1539 let signalled = signal_listed(plan.procs);
1540 if plan.freeze >= 0 {
1541 write_control(plan.freeze, b"0");
1542 }
1543 if signalled == 0 || monotonic_ns() - start >= deadline_ns {
1544 return;
1545 }
1546 let pause = libc::timespec {
1547 tv_sec: 0,
1548 tv_nsec: KILL_POLL_INTERVAL.as_nanos() as libc::c_long,
1549 };
1550 libc::nanosleep(&pause, std::ptr::null_mut());
1551 }
1552 }
1553}
1554
1555#[cfg(target_os = "linux")]
1561#[allow(unsafe_code)]
1562unsafe fn write_control(fd: libc::c_int, value: &[u8]) {
1563 unsafe {
1565 libc::lseek(fd, 0, libc::SEEK_SET);
1566 libc::write(fd, value.as_ptr().cast(), value.len());
1567 }
1568}
1569
1570#[cfg(target_os = "linux")]
1577#[allow(unsafe_code)]
1578unsafe fn signal_listed(procs: libc::c_int) -> usize {
1579 unsafe {
1581 if procs < 0 || libc::lseek(procs, 0, libc::SEEK_SET) < 0 {
1582 return 0;
1583 }
1584 let mut buf = [0u8; 512];
1585 let mut pid: libc::pid_t = 0;
1586 let mut digits = false;
1587 let mut signalled = 0;
1588 loop {
1589 let read = libc::read(procs, buf.as_mut_ptr().cast(), buf.len());
1590 if read < 0 && *libc::__errno_location() == libc::EINTR {
1591 continue;
1592 }
1593 if read <= 0 {
1594 break;
1595 }
1596 for &byte in &buf[..read as usize] {
1597 if byte.is_ascii_digit() {
1598 pid = pid
1599 .saturating_mul(10)
1600 .saturating_add(libc::pid_t::from(byte - b'0'));
1601 digits = true;
1602 } else {
1603 if digits && pid > 0 {
1604 libc::kill(pid, libc::SIGKILL);
1605 signalled += 1;
1606 }
1607 pid = 0;
1608 digits = false;
1609 }
1610 }
1611 }
1612 if digits && pid > 0 {
1613 libc::kill(pid, libc::SIGKILL);
1614 signalled += 1;
1615 }
1616 signalled
1617 }
1618}
1619
1620#[cfg(target_os = "linux")]
1626#[allow(unsafe_code)]
1627unsafe fn monotonic_ns() -> i64 {
1628 unsafe {
1630 let mut now: libc::timespec = std::mem::zeroed();
1631 libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now);
1632 now.tv_sec as i64 * 1_000_000_000 + now.tv_nsec as i64
1633 }
1634}
1635
1636#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1641fn format_pid(pid: i32, buf: &mut [u8; 24]) -> usize {
1642 let mut digits = [0u8; 20];
1643 let mut count = 0;
1644 let mut value = pid.unsigned_abs() as u64;
1645 if value == 0 {
1646 digits[0] = b'0';
1647 count = 1;
1648 }
1649 while value > 0 {
1650 digits[count] = b'0' + (value % 10) as u8;
1651 value /= 10;
1652 count += 1;
1653 }
1654
1655 let mut written = 0;
1656 for index in (0..count).rev() {
1657 buf[written] = digits[index];
1658 written += 1;
1659 }
1660 buf[written] = b'\n';
1661 written + 1
1662}
1663
1664#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1669fn read_event_counter(path: &Path, key: &str) -> u64 {
1670 let Ok(contents) = std::fs::read_to_string(path) else {
1671 return 0;
1672 };
1673 parse_event_counter(&contents, key)
1674}
1675
1676#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1678fn parse_event_counter(contents: &str, key: &str) -> u64 {
1679 for line in contents.lines() {
1680 let mut parts = line.split_whitespace();
1681 if parts.next() == Some(key) {
1682 return parts
1683 .next()
1684 .and_then(|value| value.parse().ok())
1685 .unwrap_or(0);
1686 }
1687 }
1688 0
1689}
1690
1691#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1703fn scope_dir_name(session_id: &str) -> String {
1704 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1705 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1706
1707 let sanitized: String = session_id
1708 .chars()
1709 .map(|c| {
1710 if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
1711 c
1712 } else {
1713 '_'
1714 }
1715 })
1716 .collect();
1717 let pid = std::process::id();
1718 if sanitized.is_empty() {
1719 format!("murmur-{pid}-{serial}")
1720 } else {
1721 format!("murmur-{sanitized}-{pid}-{serial}")
1722 }
1723}
1724
1725#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1731fn parse_unified_cgroup_path(contents: &str) -> Option<String> {
1732 for line in contents.lines() {
1733 let mut parts = line.splitn(3, ':');
1734 let (Some("0"), Some(""), Some(path)) = (parts.next(), parts.next(), parts.next()) else {
1735 continue;
1736 };
1737 if path.starts_with('/') {
1738 return Some(path.to_string());
1739 }
1740 }
1741 None
1742}
1743
1744#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1747fn parse_cgroup2_mount(contents: &str) -> String {
1748 for line in contents.lines() {
1749 let mut parts = line.split_whitespace();
1750 let (Some(_source), Some(target), Some(fstype)) =
1751 (parts.next(), parts.next(), parts.next())
1752 else {
1753 continue;
1754 };
1755 if fstype == "cgroup2" {
1756 return target.to_string();
1757 }
1758 }
1759 DEFAULT_CGROUP2_MOUNT.to_string()
1760}
1761
1762#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1764fn missing_controllers(subtree_control: &str, wanted: &[&str]) -> Vec<String> {
1765 let enabled: Vec<&str> = subtree_control.split_whitespace().collect();
1766 wanted
1767 .iter()
1768 .filter(|controller| !enabled.contains(*controller))
1769 .map(|controller| (*controller).to_string())
1770 .collect()
1771}
1772
1773#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1776fn device_major_minor(dev: u64) -> (u64, u64) {
1777 let major = ((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff);
1778 let minor = (dev & 0xff) | ((dev >> 12) & !0xff);
1779 (major, minor)
1780}
1781
1782#[cfg(target_os = "linux")]
1783impl CgroupScope {
1784 fn create(
1791 limits: &[ScopeLimit],
1792 io: IoMaxWrite,
1793 session_id: &str,
1794 workdir: &Path,
1795 ) -> Result<(Self, IoMaxReport), String> {
1796 let base = delegated_base()?;
1797
1798 let path = base.join(scope_dir_name(session_id));
1799 create_scope_dir(&path).map_err(|error| scope_dir_refusal(&path, &error))?;
1800
1801 let scope = Self {
1802 path,
1803 procs_fd: None,
1804 occupancy: Arc::default(),
1805 watcher: None,
1806 };
1807
1808 for limit in limits {
1809 scope.write_limit(limit)?;
1810 }
1811 let io_max = match io {
1812 IoMaxWrite::Declared(bytes_per_sec) => scope.apply_io_max(bytes_per_sec, workdir),
1813 IoMaxWrite::Undeclared(bytes_per_sec) => {
1814 IoMaxReport::not_required(bytes_per_sec, UNCONFINED_IO_UNDECLARED_REASON)
1815 }
1816 };
1817
1818 let procs_fd = open_write_only(&scope.path.join("cgroup.procs")).map_err(|error| {
1821 format!(
1822 "could not open {}/cgroup.procs for subprocess placement: {error}",
1823 scope.path.display()
1824 )
1825 })?;
1826
1827 let watcher = ScopeWatcher::spawn(&scope.path).map_err(|error| {
1828 format!(
1829 "could not start the watcher that kills {} if the runtime dies: {error}",
1830 scope.path.display()
1831 )
1832 })?;
1833
1834 Ok((
1835 Self {
1836 path: scope.take_path(),
1837 procs_fd: Some(procs_fd),
1838 occupancy: Arc::default(),
1839 watcher: Some(watcher),
1840 },
1841 io_max,
1842 ))
1843 }
1844
1845 fn take_path(mut self) -> PathBuf {
1848 let path = std::mem::take(&mut self.path);
1849 path
1851 }
1852
1853 fn write_limit(&self, limit: &ScopeLimit) -> Result<(), String> {
1856 let path = self.path.join(limit.file);
1857 let value = &limit.value;
1858 match write_existing(&path, &format!("{value}\n")) {
1859 Err(error) if limit.absent_ok && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1860 written => written
1861 .map_err(|error| format!("could not write {value} to {}: {error}", path.display())),
1862 }
1863 }
1864
1865 fn apply_io_max(&self, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1877 write_io_max(&self.path, bytes_per_sec, workdir)
1878 }
1879}
1880
1881#[cfg(target_os = "linux")]
1891fn write_io_max(scope_path: &Path, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1892 let (major, minor) = match io_max_device(workdir) {
1893 Ok(device) => device,
1894 Err(reason) => return IoMaxReport::unavailable(bytes_per_sec, reason),
1895 };
1896 let value = format!("{major}:{minor} rbps={bytes_per_sec} wbps={bytes_per_sec}");
1897 match std::fs::write(scope_path.join("io.max"), format!("{value}\n")) {
1898 Ok(()) => IoMaxReport::enforced(bytes_per_sec),
1899 Err(error) => IoMaxReport::unavailable(
1905 bytes_per_sec,
1906 format!("writing `{value}` to the scope's io.max: {error}"),
1907 ),
1908 }
1909}
1910
1911#[cfg(target_os = "linux")]
1929fn io_max_device(path: &Path) -> Result<(u64, u64), String> {
1930 use std::os::linux::fs::MetadataExt;
1931
1932 let Some(existing) = nearest_existing_ancestor(path) else {
1933 return Err(format!(
1934 "could not stat {} or any ancestor of it to find its backing device",
1935 path.display()
1936 ));
1937 };
1938 let metadata = existing.metadata().map_err(|error| {
1939 format!(
1940 "could not stat {} to find its backing device: {error}",
1941 existing.display()
1942 )
1943 })?;
1944 let (major, minor) = device_major_minor(metadata.st_dev());
1945 let (major, minor) = if major == 0 {
1946 mount_source_device(existing)?
1947 } else {
1948 (major, minor)
1949 };
1950 Ok(whole_disk_device(major, minor))
1951}
1952
1953#[cfg(target_os = "linux")]
1959fn mount_source_device(path: &Path) -> Result<(u64, u64), String> {
1960 use std::os::unix::fs::{FileTypeExt, MetadataExt};
1961
1962 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1963 let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(|error| {
1964 format!(
1965 "reading /proc/self/mountinfo to find the device behind {}: {error}",
1966 resolved.display()
1967 )
1968 })?;
1969 let Some(mount) = mount_covering(&mountinfo, &resolved) else {
1970 return Err(format!(
1971 "no mount in /proc/self/mountinfo covers {}, so its backing device is unknown",
1972 resolved.display()
1973 ));
1974 };
1975 match std::fs::metadata(&mount.source) {
1976 Ok(metadata) if metadata.file_type().is_block_device() => {
1977 Ok(device_major_minor(metadata.rdev()))
1978 }
1979 _ => Err(format!(
1980 "the filesystem mounted at {} is backed by `{}`, which is not a block device, so no \
1981 io.max ceiling can name one",
1982 mount.mount_point.display(),
1983 mount.source.display()
1984 )),
1985 }
1986}
1987
1988#[cfg(target_os = "linux")]
1996fn whole_disk_device(major: u64, minor: u64) -> (u64, u64) {
1997 let device = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
1998 if !device.join("partition").exists() {
1999 return (major, minor);
2000 }
2001 std::fs::read_to_string(device.join("../dev"))
2002 .ok()
2003 .and_then(|contents| parse_dev_line(&contents))
2004 .unwrap_or((major, minor))
2005}
2006
2007#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2009fn parse_dev_line(contents: &str) -> Option<(u64, u64)> {
2010 let (major, minor) = contents.trim().split_once(':')?;
2011 Some((major.parse().ok()?, minor.parse().ok()?))
2012}
2013
2014#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2017#[derive(Debug, PartialEq, Eq)]
2018struct MountEntry {
2019 mount_point: PathBuf,
2021 source: PathBuf,
2024}
2025
2026#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2031fn mount_covering(mountinfo: &str, path: &Path) -> Option<MountEntry> {
2032 let mut best: Option<(usize, MountEntry)> = None;
2033 for line in mountinfo.lines() {
2034 let Some(entry) = parse_mount_entry(line) else {
2035 continue;
2036 };
2037 if !path.starts_with(&entry.mount_point) {
2038 continue;
2039 }
2040 let depth = entry.mount_point.components().count();
2041 if best.as_ref().is_none_or(|(best, _)| depth >= *best) {
2042 best = Some((depth, entry));
2043 }
2044 }
2045 best.map(|(_, entry)| entry)
2046}
2047
2048#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2053fn parse_mount_entry(line: &str) -> Option<MountEntry> {
2054 let (mounted, backing) = line.split_once(" - ")?;
2055 let mount_point = mounted.split_whitespace().nth(4)?;
2056 let source = backing.split_whitespace().nth(1)?;
2057 Some(MountEntry {
2058 mount_point: unescape_octal(mount_point),
2059 source: unescape_octal(source),
2060 })
2061}
2062
2063#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2066fn unescape_octal(field: &str) -> PathBuf {
2067 use std::os::unix::ffi::OsStringExt;
2068
2069 let bytes = field.as_bytes();
2070 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
2071 let mut index = 0;
2072 while index < bytes.len() {
2073 let escape = bytes[index] == b'\\'
2074 && index + 3 < bytes.len()
2075 && bytes[index + 1..index + 4]
2076 .iter()
2077 .all(|byte| (b'0'..=b'7').contains(byte));
2078 if escape {
2079 let digits = &field[index + 1..index + 4];
2080 out.push(u8::from_str_radix(digits, 8).expect("three octal digits"));
2081 index += 4;
2082 } else {
2083 out.push(bytes[index]);
2084 index += 1;
2085 }
2086 }
2087 PathBuf::from(std::ffi::OsString::from_vec(out))
2088}
2089
2090#[cfg(target_os = "linux")]
2096fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
2097 let mut candidate = Some(path);
2098 while let Some(current) = candidate {
2099 if current.exists() {
2100 return Some(current);
2101 }
2102 candidate = current.parent();
2103 }
2104 None
2105}
2106
2107#[cfg(all(test, target_os = "linux"))]
2108pub(crate) static DELEGATED_BASE_RESOLUTION_SPAWNS: std::sync::atomic::AtomicUsize =
2109 std::sync::atomic::AtomicUsize::new(0);
2110
2111#[cfg(all(test, target_os = "linux"))]
2124pub(crate) fn resolve_delegated_base_in_background() {
2125 resolve_delegated_base_in_background_with(&[]);
2126}
2127
2128#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2132static COMPANION_PIDS: std::sync::Mutex<Vec<u32>> = std::sync::Mutex::new(Vec::new());
2133
2134#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2135fn companion_pids() -> Vec<u32> {
2136 COMPANION_PIDS
2137 .lock()
2138 .unwrap_or_else(std::sync::PoisonError::into_inner)
2139 .clone()
2140}
2141
2142pub(crate) fn await_delegated_base() -> Result<(), String> {
2150 #[cfg(target_os = "linux")]
2151 {
2152 #[cfg(test)]
2153 if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
2154 return Err("forced: delegation refused by the test seam".to_string());
2155 }
2156 delegated_base()?;
2157 }
2158 Ok(())
2159}
2160
2161pub(crate) fn resolve_delegated_base_in_background_with(companions: &[u32]) {
2164 {
2165 let mut registered = COMPANION_PIDS
2166 .lock()
2167 .unwrap_or_else(std::sync::PoisonError::into_inner);
2168 for pid in companions {
2169 if !registered.contains(pid) {
2170 registered.push(*pid);
2171 }
2172 }
2173 }
2174 #[cfg(target_os = "linux")]
2175 {
2176 #[cfg(test)]
2177 DELEGATED_BASE_RESOLUTION_SPAWNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2178 let companions = companions.to_vec();
2179 let _ = std::thread::Builder::new()
2180 .name("murmur-cgroup-base".to_string())
2181 .spawn(move || {
2182 let _ = delegated_base();
2183 COMPANION_PIDS
2187 .lock()
2188 .unwrap_or_else(std::sync::PoisonError::into_inner)
2189 .retain(|pid| !companions.contains(pid));
2190 });
2191 }
2192}
2193
2194#[cfg(target_os = "linux")]
2208fn delegated_base() -> Result<PathBuf, CgroupError> {
2209 static BASE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2210
2211 let mut cached = BASE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2214 if let Some(base) = cached.as_ref() {
2215 return Ok(base.clone());
2216 }
2217
2218 move_self_into_own_systemd_scope();
2221
2222 let base = probe_delegation()?;
2223 enable_controllers(&base)?;
2224 *cached = Some(base.clone());
2225 Ok(base)
2226}
2227
2228#[cfg(target_os = "linux")]
2238fn move_self_into_own_systemd_scope() {
2239 static ATTEMPTED: std::sync::Once = std::sync::Once::new();
2240 ATTEMPTED.call_once(|| {
2241 let _ = request_transient_scope(&transient_scope_unit_name());
2242 });
2243}
2244
2245#[cfg(target_os = "linux")]
2252fn transient_scope_unit_name() -> String {
2253 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2254 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2255 format!("murmur-{}-{serial}.scope", std::process::id())
2256}
2257
2258#[cfg(target_os = "linux")]
2261fn request_transient_scope(unit: &str) -> Result<(), String> {
2262 let owned_unit = unit.to_string();
2265 std::thread::Builder::new()
2266 .name("murmur-cgroup-scope".to_string())
2267 .spawn(move || start_transient_scope(&owned_unit))
2268 .map_err(|error| format!("could not spawn the D-Bus thread: {error}"))?
2269 .join()
2270 .map_err(|_| "the D-Bus thread panicked".to_string())??;
2271
2272 await_scope_membership(unit)
2273}
2274
2275#[cfg(target_os = "linux")]
2277fn scope_pids() -> Vec<u32> {
2278 let mut pids = vec![std::process::id()];
2279 pids.extend(
2280 companion_pids()
2281 .into_iter()
2282 .filter(|pid| Path::new(&format!("/proc/{pid}")).exists()),
2283 );
2284 pids
2285}
2286
2287#[cfg(target_os = "linux")]
2304fn start_transient_scope(unit: &str) -> Result<(), String> {
2305 use zbus::Variant;
2306
2307 let properties: Vec<(&str, Variant<'_>)> = vec![
2308 (
2309 "Description",
2310 Variant::from("Murmur capsule subprocess scope"),
2311 ),
2312 ("PIDs", Variant::from(scope_pids())),
2313 ("Delegate", Variant::from(true)),
2314 ("CollectMode", Variant::from("inactive-or-failed")),
2315 ];
2316 let aux: Vec<(&str, Vec<(&str, Variant<'_>)>)> = Vec::new();
2318
2319 zbus::block_on(async {
2320 let connection = zbus::Connection::session()
2321 .await
2322 .map_err(|error| format!("no systemd session bus: {error}"))?;
2323 connection
2324 .call_method(
2325 Some("org.freedesktop.systemd1"),
2326 "/org/freedesktop/systemd1",
2327 Some("org.freedesktop.systemd1.Manager"),
2328 "StartTransientUnit",
2329 &(unit, "fail", properties, aux),
2332 )
2333 .await
2334 .map(|_| ())
2335 .map_err(|error| format!("systemd refused to start {unit}: {error}"))
2336 })
2337}
2338
2339#[cfg(target_os = "linux")]
2353fn await_scope_membership(unit: &str) -> Result<(), String> {
2354 let deadline = std::time::Instant::now() + Duration::from_secs(2);
2355 loop {
2356 let current = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
2357 if let Some(path) = parse_unified_cgroup_path(¤t) {
2358 if path.rsplit('/').next() == Some(unit) {
2359 return Ok(());
2360 }
2361 }
2362 if std::time::Instant::now() >= deadline {
2363 return Err(format!(
2364 "systemd accepted the request for {unit} but this process is not in it"
2365 ));
2366 }
2367 std::thread::sleep(Duration::from_millis(1));
2368 }
2369}
2370
2371#[cfg(target_os = "linux")]
2379fn probe_delegation() -> Result<PathBuf, CgroupError> {
2380 let self_cgroup = std::fs::read_to_string("/proc/self/cgroup").map_err(|error| {
2381 CgroupError::io(format!("could not read /proc/self/cgroup: {error}"), &error)
2382 })?;
2383 let relative = parse_unified_cgroup_path(&self_cgroup).ok_or_else(|| {
2384 "this host has no cgroup v2 unified hierarchy (no `0::` line in /proc/self/cgroup); \
2385 cgroup v1-only and hybrid hosts cannot be delegated a v2 scope"
2386 .to_string()
2387 })?;
2388
2389 let mount = std::fs::read_to_string("/proc/mounts")
2390 .map(|contents| parse_cgroup2_mount(&contents))
2391 .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
2392
2393 let base = ascend_out_of_supervisor_leaves(
2394 PathBuf::from(mount).join(relative.trim_start_matches('/')),
2395 );
2396 if !base.is_dir() {
2397 return Err(format!(
2398 "delegated cgroup directory {} does not exist",
2399 base.display()
2400 )
2401 .into());
2402 }
2403
2404 let controllers =
2405 std::fs::read_to_string(base.join("cgroup.controllers")).map_err(|error| {
2406 CgroupError::io(
2407 format!(
2408 "could not read {}/cgroup.controllers: {error}",
2409 base.display()
2410 ),
2411 &error,
2412 )
2413 })?;
2414 let unavailable = missing_controllers(&controllers, REQUIRED_CONTROLLERS);
2415 if !unavailable.is_empty() {
2416 return Err(format!(
2417 "cgroup controllers [{}] are not delegated to {}; the unit `mur` runs under needs \
2418 `Delegate=yes` (or `Delegate=memory pids cpu io`)",
2419 unavailable.join(", "),
2420 base.display()
2421 )
2422 .into());
2423 }
2424
2425 let probe_dir = base.join(format!("murmur-delegation-probe-{}", std::process::id()));
2429 let _ = std::fs::remove_dir(&probe_dir);
2430 std::fs::create_dir(&probe_dir).map_err(|error| {
2431 CgroupError::io(
2432 format!(
2433 "cannot create a child cgroup under {} ({error}); the unit `mur` runs under needs \
2434 `Delegate=yes` for memory, pids, cpu and io",
2435 base.display()
2436 ),
2437 &error,
2438 )
2439 })?;
2440 let _ = std::fs::remove_dir(&probe_dir);
2441
2442 Ok(base)
2443}
2444
2445#[cfg(target_os = "linux")]
2452fn enable_controllers(base: &Path) -> Result<(), CgroupError> {
2453 let subtree_control = base.join("cgroup.subtree_control");
2454 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
2455
2456 let available = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
2459 let mut wanted: Vec<&str> = REQUIRED_CONTROLLERS.to_vec();
2460 if available.split_whitespace().any(|c| c == "io") {
2461 wanted.push("io");
2462 }
2463
2464 let missing = missing_controllers(¤t, &wanted);
2465 if missing.is_empty() {
2466 return Ok(());
2467 }
2468 let directive = missing
2469 .iter()
2470 .map(|controller| format!("+{controller}"))
2471 .collect::<Vec<_>>()
2472 .join(" ");
2473
2474 if std::fs::write(&subtree_control, format!("{directive}\n")).is_ok() {
2475 return Ok(());
2476 }
2477
2478 move_self_to_supervisor_leaf(base)?;
2479
2480 let deadline = std::time::Instant::now() + Duration::from_secs(2);
2491 let mut last_error;
2494 loop {
2495 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
2496 if missing_controllers(¤t, &wanted).is_empty() {
2497 return Ok(());
2498 }
2499 match std::fs::write(&subtree_control, format!("{directive}\n")) {
2500 Ok(()) => return Ok(()),
2501 Err(error) => last_error = Some(error),
2502 }
2503 if std::time::Instant::now() >= deadline {
2504 break;
2505 }
2506 std::thread::sleep(Duration::from_millis(20));
2507 }
2508
2509 Err(CgroupError {
2510 errno: last_error.as_ref().and_then(std::io::Error::raw_os_error),
2511 message: format!(
2512 "could not enable controllers [{}] in {} ({}); the unit `mur` runs under needs \
2513 `Delegate=yes` for memory, pids, cpu and io",
2514 missing.join(", "),
2515 subtree_control.display(),
2516 last_error.map_or_else(|| "still occupied".to_string(), |error| error.to_string()),
2517 ),
2518 })
2519}
2520
2521#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2532fn ascend_out_of_supervisor_leaves(mut base: PathBuf) -> PathBuf {
2533 while base.file_name().and_then(|name| name.to_str()) == Some(SUPERVISOR_LEAF) {
2534 base.pop();
2535 }
2536 base
2537}
2538
2539#[cfg(target_os = "linux")]
2541fn move_self_to_supervisor_leaf(base: &Path) -> Result<(), CgroupError> {
2542 let leaf = base.join(SUPERVISOR_LEAF);
2543 if let Err(error) = std::fs::create_dir(&leaf) {
2544 if error.kind() != std::io::ErrorKind::AlreadyExists {
2545 return Err(CgroupError::io(
2546 format!(
2547 "could not create {} to vacate the delegated cgroup: {error}",
2548 leaf.display()
2549 ),
2550 &error,
2551 ));
2552 }
2553 }
2554 std::fs::write(
2555 leaf.join("cgroup.procs"),
2556 format!("{}\n", std::process::id()),
2557 )
2558 .map_err(|error| {
2559 CgroupError::io(
2560 format!(
2561 "could not move this process into {} to vacate the delegated cgroup: {error}",
2562 leaf.display()
2563 ),
2564 &error,
2565 )
2566 })?;
2567 for pid in companion_pids() {
2570 let _ = std::fs::write(leaf.join("cgroup.procs"), format!("{pid}\n"));
2571 }
2572 Ok(())
2573}
2574
2575#[cfg(target_os = "linux")]
2581#[allow(unsafe_code)]
2582fn open_write_only(path: &Path) -> std::io::Result<std::os::fd::OwnedFd> {
2583 use std::os::unix::fs::OpenOptionsExt;
2584
2585 std::fs::OpenOptions::new()
2586 .write(true)
2587 .custom_flags(libc::O_CLOEXEC)
2588 .open(path)
2589 .map(std::os::fd::OwnedFd::from)
2590}
2591
2592pub fn cgroup_delegation_available() -> bool {
2607 #[cfg(not(target_os = "linux"))]
2608 {
2609 true
2610 }
2611
2612 #[cfg(target_os = "linux")]
2613 {
2614 delegated_base().is_ok()
2620 }
2621}
2622
2623#[must_use]
2632pub fn io_max_device_available(path: &Path) -> bool {
2633 #[cfg(not(target_os = "linux"))]
2634 {
2635 let _ = path;
2636 false
2637 }
2638
2639 #[cfg(target_os = "linux")]
2640 {
2641 io_max_device(path).is_ok()
2642 }
2643}
2644
2645pub fn skip_without_host_support(test_name: &str) -> bool {
2653 if crate::network_namespace::skip_without_egress_namespace(test_name) {
2654 return true;
2655 }
2656 if !cgroup_delegation_available() {
2657 crate::runtime_err!(
2658 "[SKIP-HOST] {test_name}: this host cannot delegate a cgroup v2 scope, so a capsule \
2659 that can spawn subprocesses refuses to launch with E-RUN-012 before anything \
2660 this test observes happens -- see {}",
2661 murmur_artifact::docs_reference_url!("resource-limits/#platform-behavior")
2662 );
2663 return true;
2664 }
2665 false
2666}
2667
2668#[cfg(test)]
2669mod tests {
2670 use super::*;
2671 use crate::types::CapabilityPolicy;
2672
2673 #[test]
2677 fn io_max_status_wire_names_are_kebab_case() {
2678 let spellings: Vec<String> = [
2679 IoMaxStatus::Enforced,
2680 IoMaxStatus::Unavailable,
2681 IoMaxStatus::NotRequired,
2682 IoMaxStatus::NotProbed,
2683 ]
2684 .into_iter()
2685 .map(|status| {
2686 serde_json::to_value(status)
2687 .unwrap()
2688 .as_str()
2689 .unwrap()
2690 .to_string()
2691 })
2692 .collect();
2693 assert_eq!(
2694 spellings,
2695 vec!["enforced", "unavailable", "not-required", "not-probed"]
2696 );
2697 }
2698
2699 #[test]
2702 fn an_unfilled_report_claims_nothing() {
2703 let report = IoMaxReport::default();
2704 assert_eq!(report.status, IoMaxStatus::NotProbed);
2705 assert_eq!(report.declared_bytes_per_sec, 0);
2706 assert_eq!(report.reason, None);
2707 assert_eq!(io_max_warning(&report, false), None);
2708 }
2709
2710 #[test]
2712 fn only_a_failed_write_omits_nothing_and_a_successful_one_explains_nothing() {
2713 let enforced = serde_json::to_value(IoMaxReport::enforced(1_000)).unwrap();
2714 assert_eq!(enforced["status"], "enforced");
2715 assert_eq!(enforced["declared_bytes_per_sec"], 1_000);
2716 assert!(
2717 enforced.get("reason").is_none(),
2718 "an applied ceiling has nothing to explain: {enforced}"
2719 );
2720
2721 let unavailable =
2722 serde_json::to_value(IoMaxReport::unavailable(1_000, "no io controller")).unwrap();
2723 assert_eq!(unavailable["status"], "unavailable");
2724 assert_eq!(unavailable["reason"], "no io controller");
2725 }
2726
2727 #[test]
2731 fn the_warning_fires_only_where_a_declared_ceiling_did_not_apply() {
2732 assert_eq!(io_max_warning(&IoMaxReport::enforced(4_096), true), None);
2733 assert_eq!(
2734 io_max_warning(&IoMaxReport::not_required(4_096, NO_SCOPE_REASON), true),
2735 None
2736 );
2737
2738 let unavailable = IoMaxReport::unavailable(4_096, "device 0:24 is not a block device");
2739 let (code, message) =
2740 io_max_warning(&unavailable, true).expect("an unapplied ceiling must be reported");
2741 assert_eq!(code, murmur_artifact::W_SEC_021);
2742 assert!(message.contains("4096 bytes/s"), "message was: {message}");
2743 assert!(
2744 message.contains("device 0:24 is not a block device"),
2745 "message was: {message}"
2746 );
2747 assert!(
2748 message.contains("memory.max, pids.max and cpu.max are still enforced on the scope"),
2749 "message was: {message}"
2750 );
2751 }
2752
2753 #[test]
2756 fn the_warning_names_cpu_max_only_when_a_cpu_cap_is_set() {
2757 let unavailable = IoMaxReport::unavailable(4_096, "no io controller");
2758 let (_, message) =
2759 io_max_warning(&unavailable, false).expect("an unapplied ceiling must be reported");
2760 assert!(
2761 message.contains("memory.max and pids.max are still enforced on the scope"),
2762 "message was: {message}"
2763 );
2764 assert!(!message.contains("cpu.max"), "message was: {message}");
2765 }
2766
2767 #[cfg(target_os = "linux")]
2770 #[test]
2771 fn the_device_is_resolved_from_the_nearest_existing_ancestor() {
2772 let temp = tempfile::tempdir().unwrap();
2773 let missing = temp
2774 .path()
2775 .join("workdir")
2776 .join("does")
2777 .join("not")
2778 .join("exist");
2779 assert_eq!(
2780 nearest_existing_ancestor(&missing),
2781 Some(temp.path()),
2782 "an ancestor of a tempdir always exists"
2783 );
2784 assert_eq!(
2785 io_max_device(&missing).ok(),
2786 io_max_device(temp.path()).ok(),
2787 "a path that does not exist yet must resolve to its filesystem's device"
2788 );
2789 assert!(!missing.exists(), "resolving a device must create nothing");
2790 }
2791
2792 #[test]
2795 fn the_covering_mount_is_the_deepest_one() {
2796 let mountinfo = "\
279725 30 0:29 / / rw,relatime shared:1 - btrfs /dev/nvme0n1p5 rw,subvol=/
279826 25 0:31 / /dev/shm rw,nosuid shared:2 - tmpfs tmpfs rw,inode64
279927 25 0:41 / /space rw,relatime shared:3 - btrfs /dev/nvme0n1p7 rw,subvol=/
2800";
2801 let covering = |path: &str| mount_covering(mountinfo, Path::new(path)).unwrap();
2802
2803 assert_eq!(
2804 covering("/space/project/workdir").source,
2805 PathBuf::from("/dev/nvme0n1p7")
2806 );
2807 assert_eq!(covering("/dev/shm/project").source, PathBuf::from("tmpfs"));
2808 assert_eq!(
2809 covering("/home/someone").source,
2810 PathBuf::from("/dev/nvme0n1p5"),
2811 "a path under no nested mount belongs to the root filesystem"
2812 );
2813 assert_eq!(
2814 covering("/spaceship").source,
2815 PathBuf::from("/dev/nvme0n1p5"),
2816 "mount points match whole path components, not string prefixes"
2817 );
2818 }
2819
2820 #[test]
2823 fn a_mount_over_another_at_the_same_point_wins() {
2824 let mountinfo = "\
282525 30 0:29 / / rw shared:1 - ext4 /dev/sda1 rw
282626 25 0:31 / /mnt rw shared:2 - ext4 /dev/sdb1 rw
282727 25 0:32 / /mnt rw shared:3 - ext4 /dev/sdc1 rw
2828";
2829 assert_eq!(
2830 mount_covering(mountinfo, Path::new("/mnt/data"))
2831 .unwrap()
2832 .source,
2833 PathBuf::from("/dev/sdc1")
2834 );
2835 }
2836
2837 #[test]
2840 fn mount_point_escapes_are_decoded() {
2841 let mountinfo = "\
284225 30 0:29 / /media/a\\040disk rw shared:1 - ext4 /dev/sdb1 rw
2843";
2844 let entry = mount_covering(mountinfo, Path::new("/media/a disk/project")).unwrap();
2845 assert_eq!(entry.mount_point, PathBuf::from("/media/a disk"));
2846 assert_eq!(entry.source, PathBuf::from("/dev/sdb1"));
2847 assert_eq!(unescape_octal("no\\134escape"), PathBuf::from("no\\escape"));
2848 assert_eq!(
2849 unescape_octal("\\04"),
2850 PathBuf::from("\\04"),
2851 "a truncated escape is not an escape"
2852 );
2853 }
2854
2855 #[test]
2857 fn sysfs_dev_attributes_are_read_as_major_minor() {
2858 assert_eq!(parse_dev_line("259:0\n"), Some((259, 0)));
2859 assert_eq!(parse_dev_line(""), None);
2860 assert_eq!(parse_dev_line("259"), None);
2861 }
2862
2863 #[cfg(target_os = "linux")]
2867 #[test]
2868 fn the_resolved_device_is_never_a_partition() {
2869 let Ok((major, minor)) = io_max_device(Path::new(".")) else {
2870 return;
2873 };
2874 let sysfs = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
2875 assert!(
2876 !sysfs.join("partition").exists(),
2877 "io.max was given the partition {major}:{minor}, which the block layer refuses"
2878 );
2879 }
2880
2881 #[cfg(target_os = "linux")]
2884 #[test]
2885 fn probing_io_max_creates_nothing_that_survives_the_call() {
2886 if skip_without_host_support("probing_io_max_creates_nothing_that_survives_the_call") {
2887 return;
2888 }
2889 let temp = tempfile::tempdir().unwrap();
2890 let probe = probe_scope(
2891 true,
2892 &HostResourceLimits::default(),
2893 &temp.path().join("workdir"),
2894 );
2895 assert_eq!(probe.cgroup_scope, CgroupScopeReport::delegated());
2896 let report = probe.io_max;
2897 assert!(
2898 matches!(
2899 report.status,
2900 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
2901 ),
2902 "a performed probe must report what it performed, got {report:?}"
2903 );
2904
2905 let leftovers: Vec<String> = std::fs::read_dir(delegated_base().expect("a delegated base"))
2909 .unwrap()
2910 .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
2911 .filter(|name| name.contains("explain-scope"))
2912 .collect();
2913 assert!(
2914 leftovers.is_empty(),
2915 "the throwaway cgroup outlived the probe: {leftovers:?}"
2916 );
2917 }
2918
2919 #[test]
2922 fn a_probe_that_is_not_required_reports_what_a_launch_writes() {
2923 let limits = HostResourceLimits::default();
2924 let probe = probe_scope(false, &limits, Path::new("/nonexistent/workdir"));
2925 let prepared = prepare_scope(false, &limits, "s", Path::new("/nonexistent/workdir"))
2926 .expect("no scope is required");
2927 assert_eq!(probe.cgroup_scope, prepared.cgroup_scope);
2928 assert_eq!(probe.io_max, prepared.io_max);
2929 assert_eq!(probe.cgroup_scope.status, CgroupScopeStatus::NotRequired);
2930 assert_eq!(
2931 serde_json::to_value(&probe.cgroup_scope).unwrap(),
2932 serde_json::json!({"status": "not-required", "reason": NO_CGROUP_SCOPE_REASON})
2933 );
2934 }
2935
2936 #[test]
2940 fn the_cgroup_scope_report_serializes_each_status() {
2941 let error = CgroupError::io(
2942 "cannot create a child cgroup under /sys/fs/cgroup/x (Read-only file system (os \
2943 error 30))"
2944 .to_string(),
2945 &std::io::Error::from_raw_os_error(libc::EROFS),
2946 );
2947 assert_eq!(error.to_string(), error.message);
2948 assert_eq!(String::from(error.clone()), error.message);
2949 let unavailable = CgroupScopeReport::unavailable(&error);
2950 assert_eq!(
2951 serde_json::to_value(&unavailable).unwrap(),
2952 serde_json::json!({
2953 "status": "unavailable",
2954 "errno": "EROFS",
2955 "reason": error.message,
2956 })
2957 );
2958 assert_eq!(
2959 render_cgroup_scope(&unavailable),
2960 format!(
2961 "cgroup scope: unavailable (EROFS) \u{2014} {}",
2962 error.message
2963 )
2964 );
2965 let delegated = CgroupScopeReport::delegated();
2966 assert_eq!(
2967 serde_json::to_value(&delegated).unwrap(),
2968 serde_json::json!({"status": "delegated"})
2969 );
2970 assert_eq!(render_cgroup_scope(&delegated), "cgroup scope: delegated");
2971 assert_eq!(
2972 serde_json::to_value(CgroupScopeReport::default()).unwrap(),
2973 serde_json::json!({"status": "not-probed"})
2974 );
2975 let no_errno = CgroupScopeReport::unavailable(&CgroupError::from("no v2".to_string()));
2976 assert_eq!(no_errno.errno, None);
2977 }
2978
2979 #[cfg(target_os = "linux")]
2982 #[test]
2983 fn a_prepared_scope_reports_delegated() {
2984 if skip_without_host_support("a_prepared_scope_reports_delegated") {
2985 return;
2986 }
2987 let temp = tempfile::tempdir().unwrap();
2988 let prepared = prepare_scope(
2989 true,
2990 &HostResourceLimits::default(),
2991 "cgroup-scope-report",
2992 &temp.path().join("workdir"),
2993 )
2994 .expect("a delegated host creates the scope");
2995 assert!(prepared.scope.is_some());
2996 assert_eq!(prepared.cgroup_scope, CgroupScopeReport::delegated());
2997 }
2998
2999 #[test]
3000 fn scope_is_required_by_any_route_to_a_subprocess() {
3001 let shell = CapabilityPolicy {
3002 shell_allow: vec!["bash".to_string()],
3003 ..CapabilityPolicy::default()
3004 };
3005 let spawn = CapabilityPolicy {
3006 spawn_allow: vec!["child".to_string()],
3007 ..CapabilityPolicy::default()
3008 };
3009 let install_tool = CapabilityPolicy {
3010 install_tool: vec!["local/tool-*".to_string()],
3011 ..CapabilityPolicy::default()
3012 };
3013 let install_skill = CapabilityPolicy {
3014 install_skill: vec!["local/skill-*".to_string()],
3015 ..CapabilityPolicy::default()
3016 };
3017 let bare = CapabilityPolicy::default();
3018
3019 assert!(requires_process_bounding(&shell));
3020 assert!(requires_process_bounding(&spawn));
3021 assert!(
3022 !requires_process_bounding(&install_tool),
3023 "a pulled tool runs a program only through murmur:process, which obeys shell.allow"
3024 );
3025 assert!(
3026 !requires_process_bounding(&install_skill),
3027 "a pulled skill gains no grant and runs no subprocess"
3028 );
3029 for policy in [&shell, &spawn, &install_tool, &install_skill, &bare] {
3030 assert_eq!(
3031 requires_process_bounding(policy),
3032 crate::runtime::has_subprocess_route_at_staging(policy),
3033 "{policy:?}"
3034 );
3035 }
3036 assert!(
3037 !requires_process_bounding(&bare),
3038 "a WASM-only capsule has no process tree to bound and must not be blocked"
3039 );
3040 }
3041
3042 #[test]
3045 fn an_unconfined_session_requires_a_scope_only_for_a_declared_cgroup_field() {
3046 use murmur_artifact::{ContainmentClass, ResourceCapabilities};
3047 let shell = |containment_floor, declared_resources| crate::types::CapabilityPolicy {
3048 shell_allow: vec!["bash".to_string()],
3049 containment_floor,
3050 declared_resources,
3051 ..Default::default()
3052 };
3053 assert!(requires_process_bounding(&shell(
3054 ContainmentClass::Advisory,
3055 ResourceCapabilities::default()
3056 )));
3057 assert!(!requires_process_bounding(&shell(
3058 ContainmentClass::Unconfined,
3059 ResourceCapabilities::default()
3060 )));
3061 assert!(!requires_process_bounding(&shell(
3062 ContainmentClass::Unconfined,
3063 ResourceCapabilities {
3064 max_open_files: Some(256),
3065 workdir_max_bytes: Some(1 << 20),
3066 ..Default::default()
3067 }
3068 )));
3069 for declared in [
3070 ResourceCapabilities {
3071 cgroup_memory_bytes: Some(1 << 30),
3072 ..Default::default()
3073 },
3074 ResourceCapabilities {
3075 cgroup_pids_max: Some(64),
3076 ..Default::default()
3077 },
3078 ResourceCapabilities {
3079 cgroup_cpu_percent: Some(50),
3080 ..Default::default()
3081 },
3082 ResourceCapabilities {
3083 cgroup_io_bytes_per_sec: Some(1 << 20),
3084 ..Default::default()
3085 },
3086 ] {
3087 assert!(
3088 requires_process_bounding(&shell(ContainmentClass::Unconfined, declared)),
3089 "{declared:?}"
3090 );
3091 let bare = crate::types::CapabilityPolicy {
3092 containment_floor: ContainmentClass::Unconfined,
3093 declared_resources: declared,
3094 ..Default::default()
3095 };
3096 assert!(
3097 !requires_process_bounding(&bare),
3098 "no subprocess route, no scope"
3099 );
3100 }
3101
3102 let temp = tempfile::tempdir().unwrap();
3104 let prepared = prepare_policy_scope(
3105 &shell(
3106 ContainmentClass::Unconfined,
3107 ResourceCapabilities::default(),
3108 ),
3109 "ses_unconfined",
3110 temp.path(),
3111 )
3112 .expect("an unconfined session with no cgroup field is never refused");
3113 assert!(prepared.scope.is_none());
3114 assert_eq!(prepared.io_max.status, IoMaxStatus::NotRequired);
3115 assert_eq!(
3116 prepared.io_max.reason.as_deref(),
3117 Some(UNCONFINED_NO_SCOPE_REASON)
3118 );
3119 }
3120
3121 #[test]
3123 fn an_unconfined_scope_writes_only_the_declared_fields() {
3124 use murmur_artifact::ResourceCapabilities;
3125 assert!(declared_scope_limits(&ResourceCapabilities::default()).is_empty());
3126 let files = |declared: ResourceCapabilities| -> Vec<(&str, String)> {
3127 declared_scope_limits(&declared)
3128 .into_iter()
3129 .map(|limit| (limit.file, limit.value))
3130 .collect()
3131 };
3132 assert_eq!(
3133 files(ResourceCapabilities {
3134 cgroup_pids_max: Some(64),
3135 max_open_files: Some(256),
3136 ..Default::default()
3137 }),
3138 vec![("pids.max", "64".to_string())]
3139 );
3140 assert_eq!(
3141 files(ResourceCapabilities {
3142 cgroup_memory_bytes: Some(1024),
3143 cgroup_cpu_percent: Some(50),
3144 ..Default::default()
3145 }),
3146 vec![
3147 ("memory.max", "1024".to_string()),
3148 ("cpu.max", format!("50000 {CPU_MAX_PERIOD_US}")),
3149 ]
3150 );
3151 }
3152
3153 #[test]
3157 fn prepare_scope_returns_none_when_bounding_is_not_required() {
3158 let temp = tempfile::tempdir().unwrap();
3159 let scope = prepare_scope(
3160 false,
3161 &HostResourceLimits::default(),
3162 "ses_test",
3163 temp.path(),
3164 )
3165 .expect("a capsule with no subprocess capability must never be refused");
3166 assert!(scope.scope.is_none());
3167 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
3169 assert!(
3170 scope.io_max.reason.is_some(),
3171 "not-required must say which cause"
3172 );
3173 assert_eq!(io_max_warning(&scope.io_max, false), None);
3174 }
3175
3176 #[cfg(not(target_os = "linux"))]
3179 #[test]
3180 fn prepare_scope_is_never_an_error_off_linux() {
3181 let temp = tempfile::tempdir().unwrap();
3182 let scope = prepare_scope(
3183 true,
3184 &HostResourceLimits::default(),
3185 "ses_test",
3186 temp.path(),
3187 )
3188 .expect("a non-Linux host must not refuse to launch for a missing cgroup");
3189 assert!(scope.scope.is_none());
3190 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
3191 assert!(scope
3192 .io_max
3193 .reason
3194 .as_deref()
3195 .expect("not-required off Linux must say cgroups cannot exist here")
3196 .contains("Linux"));
3197 }
3198
3199 #[cfg(target_os = "linux")]
3204 #[test]
3205 #[allow(clippy::print_stdout)]
3206 fn a_required_scope_is_created_whatever_cgroup_this_process_inherited() {
3207 if skip_without_host_support(
3208 "a_required_scope_is_created_whatever_cgroup_this_process_inherited",
3209 ) {
3210 return;
3211 }
3212 let temp = tempfile::tempdir().unwrap();
3213 let prepared = prepare_scope(
3214 true,
3215 &HostResourceLimits::default(),
3216 "ses_selftest",
3217 temp.path(),
3218 )
3219 .expect("a Linux host with a systemd user session must be able to bound a subprocess tree");
3220 let io_max = prepared.io_max.clone();
3221 let scope = prepared
3222 .scope
3223 .expect("a required scope on Linux is never `None`");
3224
3225 for file in ["memory.max", "pids.max"] {
3226 let contents = std::fs::read_to_string(scope.path().join(file))
3227 .unwrap_or_else(|error| panic!("{file} unreadable in the scope: {error}"));
3228 assert!(!contents.trim().is_empty(), "{file} was left unset");
3229 assert_ne!(contents.trim(), "max", "{file} carries no ceiling");
3230 }
3231 let cpu_max = std::fs::read_to_string(scope.path().join("cpu.max"))
3233 .unwrap_or_else(|error| panic!("cpu.max unreadable in the scope: {error}"));
3234 assert_eq!(cpu_max.trim(), "max 100000");
3235 match std::fs::read_to_string(scope.path().join("memory.swap.max")) {
3236 Ok(contents) => {
3237 assert_eq!(
3238 contents.trim(),
3239 SCOPE_SWAP_MAX,
3240 "memory.swap.max was not bounded"
3241 );
3242 assert_eq!(contents.trim(), "0");
3243 println!(
3244 "memory.swap.max in the created scope reads {}",
3245 contents.trim()
3246 );
3247 }
3248 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3249 assert_eq!(probe_swap_control(), SwapControl::Absent);
3250 println!("this kernel exposes no memory.swap.max; swap is not bounded here");
3251 }
3252 Err(error) => panic!("memory.swap.max unreadable in the scope: {error}"),
3253 }
3254
3255 assert!(
3258 matches!(
3259 io_max.status,
3260 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
3261 ),
3262 "a created scope must report a performed io.max write, got {io_max:?}"
3263 );
3264 assert_eq!(
3265 io_max.declared_bytes_per_sec,
3266 HostResourceLimits::default().cgroup_io_bytes_per_sec
3267 );
3268 if io_max.status == IoMaxStatus::Unavailable {
3269 assert!(io_max.reason.is_some(), "unavailable must carry a reason");
3270 assert!(io_max_warning(&io_max, false).is_some());
3271 } else {
3272 assert_eq!(io_max.reason, None);
3273 assert_eq!(io_max_warning(&io_max, false), None);
3274 }
3275 }
3276
3277 #[cfg(target_os = "linux")]
3279 #[test]
3280 fn a_declared_cpu_cap_is_written_to_the_scope() {
3281 if skip_without_host_support("a_declared_cpu_cap_is_written_to_the_scope") {
3282 return;
3283 }
3284 let temp = tempfile::tempdir().unwrap();
3285 let limits = HostResourceLimits {
3286 cgroup_cpu_percent: Some(50),
3287 ..HostResourceLimits::default()
3288 };
3289 let scope = prepare_scope(true, &limits, "ses_cpucap", temp.path())
3290 .expect(
3291 "a Linux host with a systemd user session must be able to bound a subprocess tree",
3292 )
3293 .scope
3294 .expect("a required scope on Linux is never `None`");
3295 let cpu_max = std::fs::read_to_string(scope.path().join("cpu.max"))
3296 .unwrap_or_else(|error| panic!("cpu.max unreadable in the scope: {error}"));
3297 assert_eq!(cpu_max.trim(), "50000 100000");
3298 }
3299
3300 #[cfg(target_os = "linux")]
3304 #[test]
3305 #[allow(clippy::print_stdout)]
3306 fn killing_a_scope_without_cgroup_kill_empties_it() {
3307 if skip_without_host_support("killing_a_scope_without_cgroup_kill_empties_it") {
3308 return;
3309 }
3310 let temp = tempfile::tempdir().unwrap();
3311 let scope = prepare_scope(
3312 true,
3313 &HostResourceLimits::default(),
3314 "ses_kill_fallback",
3315 temp.path(),
3316 )
3317 .expect("a delegated scope")
3318 .scope
3319 .expect("a required scope on Linux is never None");
3320 let enforcement = crate::sandbox::ShellEnforcement {
3321 cgroup_scope: Some(Arc::clone(&scope)),
3322 ..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
3323 };
3324 let mut command = std::process::Command::new("bash");
3325 command
3326 .args([
3327 "-c",
3328 "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
3329 ])
3330 .stdout(std::process::Stdio::null())
3331 .stderr(std::process::Stdio::null());
3332 crate::sandbox::attach_process_limits(&mut command, &enforcement);
3333 let mut child = command.spawn().expect("bash spawns into the scope");
3334
3335 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3336 while scope.member_pids().len() < 3 {
3337 assert!(
3338 std::time::Instant::now() < deadline,
3339 "the scope never held bash and both sleepers: {:?}",
3340 scope.member_pids()
3341 );
3342 std::thread::sleep(std::time::Duration::from_millis(20));
3343 }
3344 let members = scope.member_pids();
3345
3346 FORCE_KILL_FALLBACK.with(|flag| flag.set(true));
3347 let killed = scope.kill_all();
3348 FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
3349 let emptied = scope.wait_until_empty(std::time::Duration::from_secs(2));
3350 let status = child.wait().expect("bash is reaped");
3351 println!(
3352 "members before: {members:?}; fallback kill: {killed:?}; empty: {emptied}; \
3353 cgroup.procs after: {:?}; bash: {status}",
3354 scope.member_pids()
3355 );
3356 killed.expect("the fallback kill succeeds");
3357 assert!(emptied, "the scope still holds {:?}", scope.member_pids());
3358 assert_eq!(
3359 std::os::unix::process::ExitStatusExt::signal(&status),
3360 Some(libc::SIGKILL)
3361 );
3362 }
3363
3364 #[cfg(target_os = "linux")]
3367 fn scope_with_sleepers(
3368 session: &str,
3369 fallback: bool,
3370 ) -> (Arc<CgroupScope>, std::process::Child) {
3371 let temp = tempfile::tempdir().unwrap();
3372 FORCE_KILL_FALLBACK.with(|flag| flag.set(fallback));
3373 let scope = prepare_scope(true, &HostResourceLimits::default(), session, temp.path());
3374 FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
3375 let scope = scope
3376 .expect("a delegated scope")
3377 .scope
3378 .expect("a required scope on Linux is never None");
3379 let enforcement = crate::sandbox::ShellEnforcement {
3380 cgroup_scope: Some(Arc::clone(&scope)),
3381 ..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
3382 };
3383 let mut command = std::process::Command::new("bash");
3384 command
3385 .args([
3386 "-c",
3387 "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
3388 ])
3389 .stdout(std::process::Stdio::null())
3390 .stderr(std::process::Stdio::null());
3391 crate::sandbox::attach_process_limits(&mut command, &enforcement);
3392 let child = command.spawn().expect("bash spawns into the scope");
3393 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3394 while scope.member_pids().len() < 3 {
3395 assert!(
3396 std::time::Instant::now() < deadline,
3397 "the scope never held bash and both sleepers: {:?}",
3398 scope.member_pids()
3399 );
3400 std::thread::sleep(std::time::Duration::from_millis(20));
3401 }
3402 (scope, child)
3403 }
3404
3405 #[cfg(target_os = "linux")]
3406 fn watcher_of(scope: &CgroupScope) -> &ScopeWatcher {
3407 scope
3408 .watcher
3409 .as_ref()
3410 .expect("a scope on Linux has a watcher")
3411 }
3412
3413 #[cfg(target_os = "linux")]
3416 fn assert_a_cut_lifeline_empties_the_scope(test_name: &str, fallback: bool) {
3417 if skip_without_host_support(test_name) {
3418 return;
3419 }
3420 let (scope, mut child) = scope_with_sleepers(test_name, fallback);
3421 let members = scope.member_pids();
3422 watcher_of(&scope).cut_lifeline();
3423 assert!(
3424 scope.wait_until_empty(std::time::Duration::from_secs(5)),
3425 "the watcher left {:?} of {members:?} in the scope",
3426 scope.member_pids()
3427 );
3428 for pid in members {
3429 assert!(
3430 !Path::new(&format!("/proc/{pid}")).exists()
3431 || std::fs::read_to_string(format!("/proc/{pid}/stat"))
3432 .is_ok_and(|stat| stat.contains(") Z ")),
3433 "{pid} is still running"
3434 );
3435 }
3436 let status = child.wait().expect("bash is reaped");
3437 assert_eq!(
3438 std::os::unix::process::ExitStatusExt::signal(&status),
3439 Some(libc::SIGKILL)
3440 );
3441 }
3442
3443 #[cfg(target_os = "linux")]
3444 #[test]
3445 fn scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes() {
3446 assert_a_cut_lifeline_empties_the_scope(
3447 "scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes",
3448 false,
3449 );
3450 }
3451
3452 #[cfg(target_os = "linux")]
3453 #[test]
3454 fn scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes() {
3455 assert_a_cut_lifeline_empties_the_scope(
3456 "scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes",
3457 true,
3458 );
3459 }
3460
3461 #[cfg(target_os = "linux")]
3465 #[test]
3466 #[allow(unsafe_code)]
3467 fn scope_watcher_holds_only_its_own_descriptors_outside_the_scope() {
3468 if skip_without_host_support(
3469 "scope_watcher_holds_only_its_own_descriptors_outside_the_scope",
3470 ) {
3471 return;
3472 }
3473 let own = tempfile::tempfile().unwrap();
3475 let own_target = std::fs::read_link(format!(
3476 "/proc/self/fd/{}",
3477 std::os::fd::AsRawFd::as_raw_fd(&own)
3478 ))
3479 .unwrap();
3480 let (scope, mut child) = scope_with_sleepers("ses_watcher_fds", false);
3481 let pid = watcher_of(&scope).pid;
3482
3483 let fd_dir = format!("/proc/{pid}/fd");
3484 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3485 let targets = loop {
3486 let targets: Vec<(String, PathBuf)> = std::fs::read_dir(&fd_dir)
3487 .unwrap()
3488 .filter_map(Result::ok)
3489 .filter_map(|entry| {
3490 let target = std::fs::read_link(entry.path()).ok()?;
3491 Some((entry.file_name().to_string_lossy().into_owned(), target))
3492 })
3493 .collect();
3494 let settled = targets.iter().all(|(_, target)| {
3495 target == Path::new("/dev/null")
3496 || target.to_string_lossy().starts_with("pipe:")
3497 || target.starts_with(scope.path())
3498 });
3499 if settled || std::time::Instant::now() >= deadline {
3500 break targets;
3501 }
3502 std::thread::sleep(std::time::Duration::from_millis(20));
3503 };
3504 let mut numbers: Vec<u32> = targets
3505 .iter()
3506 .map(|(name, _)| name.parse().unwrap())
3507 .collect();
3508 numbers.sort_unstable();
3509 assert!(numbers.starts_with(&[0, 1, 2]), "{targets:?}");
3510 for (name, target) in &targets {
3511 assert_ne!(target, &own_target, "fd {name}");
3512 if ["0", "1", "2"].contains(&name.as_str()) {
3513 assert_eq!(target, Path::new("/dev/null"), "fd {name}");
3514 } else {
3515 assert!(
3516 target.to_string_lossy().starts_with("pipe:")
3517 || target.starts_with(scope.path()),
3518 "fd {name} -> {}",
3519 target.display()
3520 );
3521 }
3522 }
3523 let pipes = targets
3524 .iter()
3525 .filter(|(_, target)| target.to_string_lossy().starts_with("pipe:"))
3526 .count();
3527 assert_eq!(pipes, 1, "the lifeline is its only pipe: {targets:?}");
3528
3529 assert!(!scope.member_pids().contains(&pid));
3530 assert_eq!(
3532 unsafe { libc::getpgid(pid) },
3533 pid,
3534 "a process group of its own"
3535 );
3536
3537 scope.kill_all().unwrap();
3538 child.wait().unwrap();
3539 }
3540
3541 #[cfg(target_os = "linux")]
3543 #[test]
3544 #[allow(unsafe_code)]
3545 fn scope_watcher_is_reaped_when_the_scope_drops() {
3546 if skip_without_host_support("scope_watcher_is_reaped_when_the_scope_drops") {
3547 return;
3548 }
3549 let (scope, mut child) = scope_with_sleepers("ses_watcher_reaped", false);
3550 let pid = watcher_of(&scope).pid;
3551 let path = scope.path().to_path_buf();
3552 drop(Arc::try_unwrap(scope).expect("the test holds the only reference"));
3553 child.wait().unwrap();
3554 let mut status = 0;
3555 let waited = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
3557 assert_eq!(waited, -1);
3558 assert_eq!(
3559 std::io::Error::last_os_error().raw_os_error(),
3560 Some(libc::ECHILD)
3561 );
3562 assert!(!path.exists(), "the scope directory is removed");
3563 }
3564
3565 #[test]
3568 fn the_scope_limits_bound_swap_at_zero() {
3569 let declared = HostResourceLimits {
3570 cgroup_memory_bytes: 268_435_456,
3571 cgroup_cpu_percent: Some(50),
3572 ..HostResourceLimits::default()
3573 };
3574 for limits in [HostResourceLimits::default(), declared] {
3575 let writes: Vec<(&str, String)> = scope_limits(&limits)
3576 .into_iter()
3577 .map(|limit| (limit.file, limit.value))
3578 .collect();
3579 assert!(
3580 writes.contains(&("memory.swap.max", "0".to_string())),
3581 "writes were {writes:?}"
3582 );
3583 assert!(writes.contains(&("memory.max", limits.cgroup_memory_bytes.to_string())));
3584 assert_eq!(
3585 &writes.iter().map(|(file, _)| *file).collect::<Vec<_>>()[..3],
3586 ["memory.max", "memory.swap.max", "pids.max"]
3587 );
3588 }
3589 let absent_ok: Vec<&str> = scope_limits(&HostResourceLimits::default())
3590 .into_iter()
3591 .filter(|limit| limit.absent_ok)
3592 .map(|limit| limit.file)
3593 .collect();
3594 assert_eq!(absent_ok, ["memory.swap.max"]);
3595 }
3596
3597 #[test]
3600 fn the_scope_limits_write_cpu_max_only_for_a_declared_cap() {
3601 let files: Vec<&str> = scope_limits(&HostResourceLimits::resolve(None))
3602 .into_iter()
3603 .map(|limit| limit.file)
3604 .collect();
3605 assert_eq!(files, ["memory.max", "memory.swap.max", "pids.max"]);
3606
3607 let capped = HostResourceLimits {
3608 cgroup_cpu_percent: Some(50),
3609 ..HostResourceLimits::default()
3610 };
3611 let writes = scope_limits(&capped);
3612 assert_eq!(writes.len(), 4);
3613 assert_eq!(writes[3].file, "cpu.max");
3614 assert_eq!(writes[3].value, "50000 100000");
3615 assert!(!writes[3].absent_ok);
3616 }
3617
3618 #[test]
3619 fn active_swap_areas_are_the_lines_below_the_header() {
3620 let header = "Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n";
3621 assert_eq!(count_swap_areas(header), 0);
3622 assert_eq!(count_swap_areas(""), 0);
3623 assert_eq!(
3624 count_swap_areas(&format!(
3625 "{header}/swapfile file\t\t33554428\t\t0\t\t-2\n"
3626 )),
3627 1
3628 );
3629 }
3630
3631 #[cfg(target_os = "linux")]
3634 #[test]
3635 fn transient_scope_unit_names_are_murmur_prefixed_and_unique() {
3636 let first = transient_scope_unit_name();
3637 let second = transient_scope_unit_name();
3638 assert!(first.starts_with(&format!("murmur-{}-", std::process::id())));
3639 assert!(first.ends_with(".scope"));
3640 assert_ne!(first, second);
3641 }
3642
3643 #[test]
3644 fn unified_cgroup_path_is_read_from_the_zero_hierarchy_line() {
3645 let v2_only = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope\n";
3646 assert_eq!(
3647 parse_unified_cgroup_path(v2_only).as_deref(),
3648 Some("/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope")
3649 );
3650
3651 let hybrid = "12:pids:/user.slice\n11:memory:/user.slice\n0::/user.slice/session.scope\n";
3652 assert_eq!(
3653 parse_unified_cgroup_path(hybrid).as_deref(),
3654 Some("/user.slice/session.scope")
3655 );
3656
3657 let v1_only = "12:pids:/user.slice\n11:memory:/user.slice\n";
3658 assert_eq!(parse_unified_cgroup_path(v1_only), None);
3659 }
3660
3661 #[test]
3662 fn cgroup2_mount_falls_back_to_the_conventional_path() {
3663 let mounts = "cgroup2 /custom/cgroup cgroup2 rw,nsdelegate 0 0\n";
3664 assert_eq!(parse_cgroup2_mount(mounts), "/custom/cgroup");
3665 assert_eq!(
3666 parse_cgroup2_mount("proc /proc proc rw 0 0\n"),
3667 "/sys/fs/cgroup"
3668 );
3669 }
3670
3671 #[test]
3672 fn missing_controllers_reports_only_what_is_absent() {
3673 assert_eq!(
3674 missing_controllers("cpu memory", REQUIRED_CONTROLLERS),
3675 vec!["pids".to_string()]
3676 );
3677 assert!(missing_controllers("cpu io memory pids", REQUIRED_CONTROLLERS).is_empty());
3678 assert_eq!(
3679 missing_controllers("", REQUIRED_CONTROLLERS),
3680 vec!["memory".to_string(), "pids".to_string(), "cpu".to_string()]
3681 );
3682 }
3683
3684 #[test]
3685 fn event_counters_parse_the_named_key_only() {
3686 let memory_events = "low 0\nhigh 0\nmax 12\noom 3\noom_kill 2\n";
3687 assert_eq!(parse_event_counter(memory_events, "oom_kill"), 2);
3688 assert_eq!(parse_event_counter(memory_events, "max"), 12);
3689 assert_eq!(parse_event_counter("max 7\n", "max"), 7);
3690 assert_eq!(parse_event_counter("", "oom_kill"), 0);
3691 assert_eq!(parse_event_counter("oom_kill\n", "oom_kill"), 0);
3692 }
3693
3694 #[test]
3697 fn attribution_uses_the_delta_across_one_call() {
3698 let before = CgroupEventCounters {
3699 oom_kill: 1,
3700 pids_max: 4,
3701 };
3702 assert_eq!(before.attribution_since(before), None);
3703 assert_eq!(
3704 CgroupEventCounters {
3705 oom_kill: 2,
3706 pids_max: 4
3707 }
3708 .attribution_since(before),
3709 Some("cgroup_memory_bytes")
3710 );
3711 assert_eq!(
3712 CgroupEventCounters {
3713 oom_kill: 1,
3714 pids_max: 5
3715 }
3716 .attribution_since(before),
3717 Some("cgroup_pids_max")
3718 );
3719 }
3720
3721 fn scope_without_kernel() -> CgroupScope {
3724 CgroupScope {
3725 path: PathBuf::new(),
3726 procs_fd: None,
3727 occupancy: Arc::default(),
3728 #[cfg(target_os = "linux")]
3729 watcher: None,
3730 }
3731 }
3732
3733 #[test]
3734 fn occupancy_alone_is_exclusive() {
3735 let scope = scope_without_kernel();
3736 let call = scope.enter();
3737 assert!(call.exclusive());
3738 }
3739
3740 #[test]
3741 fn occupancy_entering_beside_another_is_not_exclusive() {
3742 let scope = scope_without_kernel();
3743 let first = scope.enter();
3744 let second = scope.enter();
3745 assert!(!second.exclusive(), "someone was already inside");
3746 drop(first);
3747 assert!(
3748 !second.exclusive(),
3749 "the sibling leaving does not clean the window it shared"
3750 );
3751 }
3752
3753 #[test]
3754 fn occupancy_someone_entering_during_the_window_makes_it_shared() {
3755 let scope = scope_without_kernel();
3756 let first = scope.enter();
3757 assert!(first.exclusive());
3758 drop(scope.enter());
3759 assert!(
3760 !first.exclusive(),
3761 "a call that entered and left inside this window could have moved the counters"
3762 );
3763 }
3764
3765 #[test]
3766 fn occupancy_drop_releases_the_scope() {
3767 let scope = scope_without_kernel();
3768 drop(scope.enter());
3769 let next = scope.enter();
3770 assert!(
3771 next.exclusive(),
3772 "an earlier call that has left does not share this window"
3773 );
3774 }
3775
3776 #[test]
3779 fn occupancy_guard_outlives_its_borrow_on_another_thread() {
3780 let scope = scope_without_kernel();
3781 let demoted = scope.enter();
3782 let held = std::thread::spawn(move || demoted);
3783 let demoted = held.join().unwrap();
3784 let foreground = scope.enter();
3785 assert!(!foreground.exclusive());
3786 assert!(!demoted.exclusive());
3787 }
3788
3789 #[test]
3790 fn scope_dir_name_is_sanitized_and_never_empty() {
3791 assert!(scope_dir_name("ses_abc123").starts_with("murmur-ses_abc123-"));
3792 assert!(scope_dir_name("../escape").starts_with("murmur-.._escape-"));
3793 assert!(!scope_dir_name("../escape").contains('/'));
3794 assert!(scope_dir_name("").starts_with("murmur-"));
3795 }
3796
3797 #[test]
3801 fn delegated_base_ascends_out_of_inherited_supervisor_leaves() {
3802 let base = PathBuf::from("/sys/fs/cgroup/user.slice/app.slice/mur.scope");
3803
3804 assert_eq!(ascend_out_of_supervisor_leaves(base.clone()), base);
3805 assert_eq!(
3806 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF)),
3807 base
3808 );
3809 assert_eq!(
3810 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF).join(SUPERVISOR_LEAF)),
3811 base,
3812 "a base already nested by an earlier build must still resolve to the real root"
3813 );
3814 }
3815
3816 #[cfg(target_os = "linux")]
3821 #[test]
3822 fn background_resolution_agrees_with_a_direct_one() {
3823 resolve_delegated_base_in_background();
3824 let first = delegated_base();
3825 let second = delegated_base();
3826 assert_eq!(first, second);
3827 }
3828
3829 #[test]
3833 fn scope_dir_name_is_unique_per_call_for_one_id() {
3834 let first = scope_dir_name("p");
3835 let second = scope_dir_name("p");
3836 assert_ne!(
3837 first, second,
3838 "same plan id must not reuse a scope directory"
3839 );
3840 }
3841
3842 #[test]
3843 fn pid_is_formatted_without_allocating() {
3844 let mut buf = [0u8; 24];
3845 let len = format_pid(4321, &mut buf);
3846 assert_eq!(&buf[..len], b"4321\n");
3847
3848 let len = format_pid(7, &mut buf);
3849 assert_eq!(&buf[..len], b"7\n");
3850 }
3851
3852 #[test]
3853 fn device_numbers_use_the_wide_glibc_encoding() {
3854 assert_eq!(device_major_minor(0x800), (8, 0));
3856 assert_eq!(device_major_minor((259 << 8) | 3), (259, 3));
3858 }
3859}