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 native 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 native 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 native 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 has_native_artifact: bool,
782 session_id: &str,
783 workdir: &Path,
784) -> Result<PreparedScope, String> {
785 let required = requires_process_bounding(policy, has_native_artifact);
786 if policy.containment_floor != murmur_artifact::ContainmentClass::Unconfined {
787 return prepare_scope(required, &policy.resources, session_id, workdir);
788 }
789 let reported_bytes_per_sec = policy.resources.cgroup_io_bytes_per_sec;
790 if !required {
791 let (io_max, cgroup_scope) = unrequired_scope_reports(policy, has_native_artifact);
792 return Ok(PreparedScope {
793 scope: None,
794 io_max,
795 cgroup_scope,
796 });
797 }
798 #[cfg(target_os = "linux")]
799 {
800 #[cfg(test)]
801 if FORCE_DELEGATION_FAILURE.with(std::cell::Cell::get) {
802 return Err("forced: delegation refused by the test seam".to_string());
803 }
804 let declared = &policy.declared_resources;
805 let io = match declared.cgroup_io_bytes_per_sec {
806 Some(bytes_per_sec) => IoMaxWrite::Declared(bytes_per_sec),
807 None => IoMaxWrite::Undeclared(reported_bytes_per_sec),
808 };
809 let (scope, io_max) =
810 CgroupScope::create(&declared_scope_limits(declared), io, session_id, workdir)?;
811 let scope = Arc::new(scope);
812 Ok(PreparedScope {
813 cgroup_scope: CgroupScopeReport::of_prepared(Some(&scope), true),
814 scope: Some(scope),
815 io_max,
816 })
817 }
818 #[cfg(not(target_os = "linux"))]
819 {
820 let _ = (session_id, workdir);
821 Ok(PreparedScope {
822 scope: None,
823 io_max: IoMaxReport::not_required(reported_bytes_per_sec, NOT_LINUX_REASON),
824 cgroup_scope: CgroupScopeReport::of_prepared(None, true),
825 })
826 }
827}
828
829fn unrequired_scope_reports(
833 policy: &crate::types::CapabilityPolicy,
834 has_native_artifact: bool,
835) -> (IoMaxReport, CgroupScopeReport) {
836 let reported_bytes_per_sec = policy.resources.cgroup_io_bytes_per_sec;
837 let has_route = crate::runtime::has_subprocess_route_at_staging(policy) || has_native_artifact;
838 if policy.containment_floor == murmur_artifact::ContainmentClass::Unconfined && has_route {
839 return (
840 IoMaxReport::not_required(reported_bytes_per_sec, UNCONFINED_NO_SCOPE_REASON),
841 CgroupScopeReport::not_required(UNCONFINED_NO_CGROUP_SCOPE_REASON),
842 );
843 }
844 (
845 IoMaxReport::no_scope_required(reported_bytes_per_sec),
846 CgroupScopeReport::no_scope_required(),
847 )
848}
849
850#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
852#[derive(Debug, Clone, Copy)]
853enum IoMaxWrite {
854 Declared(u64),
856 Undeclared(u64),
859}
860
861#[derive(Debug, Clone, PartialEq, Eq)]
864pub struct ScopeProbe {
865 pub cgroup_scope: CgroupScopeReport,
867 pub io_max: IoMaxReport,
869}
870
871#[must_use]
893pub fn probe_scope(required: bool, limits: &HostResourceLimits, workdir: &Path) -> ScopeProbe {
894 let declared = limits.cgroup_io_bytes_per_sec;
895 if !required {
896 return ScopeProbe {
897 cgroup_scope: CgroupScopeReport::no_scope_required(),
898 io_max: IoMaxReport::no_scope_required(declared),
899 };
900 }
901 #[cfg(target_os = "linux")]
902 {
903 let base = match delegated_base() {
904 Ok(base) => base,
905 Err(error) => {
906 return ScopeProbe {
907 cgroup_scope: CgroupScopeReport::unavailable(&error),
908 io_max: IoMaxReport::unavailable(
909 declared,
910 format!("no delegated cgroup base to write io.max into: {error}"),
911 ),
912 }
913 }
914 };
915 let path = base.join(scope_dir_name("explain-scope"));
916 if let Err(error) = create_scope_dir(&path) {
917 return ScopeProbe {
918 cgroup_scope: CgroupScopeReport::unavailable(&scope_dir_refusal(&path, &error)),
919 io_max: IoMaxReport::unavailable(
920 declared,
921 format!(
922 "could not create a throwaway cgroup at {}: {error}",
923 path.display()
924 ),
925 ),
926 };
927 }
928 let io_max = write_io_max(&path, declared, workdir);
929 let _ = std::fs::remove_dir(&path);
932 ScopeProbe {
933 cgroup_scope: CgroupScopeReport::delegated(),
934 io_max,
935 }
936 }
937 #[cfg(not(target_os = "linux"))]
938 {
939 let _ = workdir;
940 ScopeProbe {
941 cgroup_scope: CgroupScopeReport::not_required(NOT_LINUX_CGROUP_REASON),
942 io_max: IoMaxReport::not_required(declared, NOT_LINUX_REASON),
943 }
944 }
945}
946
947#[cfg(target_os = "linux")]
950fn create_scope_dir(path: &Path) -> std::io::Result<()> {
951 match std::fs::create_dir(path) {
952 Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => Err(error),
953 _ => Ok(()),
954 }
955}
956
957#[cfg(target_os = "linux")]
959fn scope_dir_refusal(path: &Path, error: &std::io::Error) -> CgroupError {
960 CgroupError::io(
961 format!(
962 "could not create cgroup scope at {}: {error}",
963 path.display()
964 ),
965 error,
966 )
967}
968
969#[must_use]
974pub fn explain_scope_probe(policy: &crate::types::CapabilityPolicy, workdir: &Path) -> ScopeProbe {
975 let required = requires_process_bounding(policy, false);
976 if policy.containment_floor != murmur_artifact::ContainmentClass::Unconfined {
977 return probe_scope(required, &policy.resources, workdir);
978 }
979 if !required {
980 let (io_max, cgroup_scope) = unrequired_scope_reports(policy, false);
981 return ScopeProbe {
982 cgroup_scope,
983 io_max,
984 };
985 }
986 let declared_io = policy.declared_resources.cgroup_io_bytes_per_sec;
987 let mut probe = probe_scope(
988 true,
989 &HostResourceLimits {
990 cgroup_io_bytes_per_sec: declared_io
991 .unwrap_or(policy.resources.cgroup_io_bytes_per_sec),
992 ..policy.resources
993 },
994 workdir,
995 );
996 if cfg!(target_os = "linux") && declared_io.is_none() {
997 probe.io_max = IoMaxReport::not_required(
998 policy.resources.cgroup_io_bytes_per_sec,
999 UNCONFINED_IO_UNDECLARED_REASON,
1000 );
1001 }
1002 probe
1003}
1004
1005pub fn requires_process_bounding(
1018 policy: &crate::types::CapabilityPolicy,
1019 has_native_artifact: bool,
1020) -> bool {
1021 let has_route = crate::runtime::has_subprocess_route_at_staging(policy) || has_native_artifact;
1022 if policy.containment_floor == murmur_artifact::ContainmentClass::Unconfined {
1023 return has_route && crate::resources::declares_cgroup_limit(&policy.declared_resources);
1024 }
1025 has_route
1026}
1027
1028impl CgroupScope {
1029 pub(crate) fn path(&self) -> &Path {
1031 &self.path
1032 }
1033
1034 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1042 #[allow(unsafe_code)]
1043 pub(crate) fn join_current_process(&self) -> std::io::Result<()> {
1044 use std::os::fd::AsRawFd;
1045
1046 let Some(fd) = self.procs_fd.as_ref() else {
1047 return Ok(());
1048 };
1049
1050 let pid = unsafe { libc::getpid() };
1052 let mut buf = [0u8; 24];
1053 let written = format_pid(pid, &mut buf);
1054
1055 let mut offset = 0;
1056 while offset < written {
1057 let rc = unsafe {
1060 libc::write(
1061 fd.as_raw_fd(),
1062 buf[offset..written].as_ptr().cast::<libc::c_void>(),
1063 written - offset,
1064 )
1065 };
1066 if rc < 0 {
1067 let error = std::io::Error::last_os_error();
1068 if error.kind() == std::io::ErrorKind::Interrupted {
1069 continue;
1070 }
1071 return Err(error);
1072 }
1073 offset += rc as usize;
1074 }
1075 Ok(())
1076 }
1077
1078 pub(crate) fn enter(&self) -> ScopeOccupancy {
1081 self.occupancy.enter()
1082 }
1083
1084 pub(crate) fn places_subprocesses(&self) -> bool {
1090 self.procs_fd.is_some()
1091 }
1092
1093 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1098 pub(crate) fn event_counters(&self) -> CgroupEventCounters {
1099 CgroupEventCounters {
1100 oom_kill: read_event_counter(&self.path.join("memory.events"), "oom_kill"),
1101 pids_max: read_event_counter(&self.path.join("pids.events"), "max"),
1102 }
1103 }
1104}
1105
1106impl CgroupScope {
1107 pub(crate) fn kill_all(&self) -> std::io::Result<()> {
1118 if self.path.as_os_str().is_empty() {
1119 return Ok(());
1120 }
1121 #[cfg(target_os = "linux")]
1122 {
1123 #[cfg(test)]
1124 let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
1125 #[cfg(not(test))]
1126 let forced = false;
1127 if !forced {
1128 match write_existing(&self.path.join("cgroup.kill"), "1") {
1129 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1130 written => return written,
1131 }
1132 }
1133 self.kill_by_freezing()
1134 }
1135 #[cfg(not(target_os = "linux"))]
1136 Ok(())
1137 }
1138
1139 pub(crate) fn wait_until_empty(&self, timeout: std::time::Duration) -> bool {
1144 let deadline = std::time::Instant::now() + timeout;
1145 loop {
1146 if self.member_pids().is_empty() {
1147 return true;
1148 }
1149 if std::time::Instant::now() >= deadline {
1150 return false;
1151 }
1152 std::thread::sleep(KILL_POLL_INTERVAL);
1153 }
1154 }
1155
1156 pub(crate) fn member_pids(&self) -> Vec<i32> {
1159 if self.path.as_os_str().is_empty() {
1160 return Vec::new();
1161 }
1162 std::fs::read_to_string(self.path.join("cgroup.procs"))
1163 .map(|procs| {
1164 procs
1165 .lines()
1166 .filter_map(|line| line.trim().parse().ok())
1167 .collect()
1168 })
1169 .unwrap_or_default()
1170 }
1171
1172 #[cfg(target_os = "linux")]
1178 #[allow(unsafe_code)]
1179 fn kill_by_freezing(&self) -> std::io::Result<()> {
1180 let freeze = self.path.join("cgroup.freeze");
1181 let deadline = std::time::Instant::now() + KILL_FALLBACK_DEADLINE;
1182 loop {
1183 let pids = self.member_pids();
1184 if pids.is_empty() {
1185 return Ok(());
1186 }
1187 let _ = write_existing(&freeze, "1");
1189 for pid in pids {
1190 unsafe { libc::kill(pid, libc::SIGKILL) };
1192 }
1193 let _ = write_existing(&freeze, "0");
1194 if std::time::Instant::now() >= deadline {
1195 return Err(std::io::Error::other(format!(
1196 "processes were still in {} after {} ms of SIGKILL",
1197 self.path.display(),
1198 KILL_FALLBACK_DEADLINE.as_millis()
1199 )));
1200 }
1201 std::thread::sleep(KILL_POLL_INTERVAL);
1202 }
1203 }
1204}
1205
1206const KILL_FALLBACK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
1208
1209const KILL_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1211
1212#[cfg(target_os = "linux")]
1215fn write_existing(path: &Path, value: &str) -> std::io::Result<()> {
1216 use std::io::Write as _;
1217 let file = std::fs::OpenOptions::new().write(true).open(path)?;
1218 (&file).write_all(value.as_bytes())
1219}
1220
1221#[cfg(all(test, target_os = "linux"))]
1224thread_local! {
1225 pub(crate) static FORCE_KILL_FALLBACK: std::cell::Cell<bool> =
1226 const { std::cell::Cell::new(false) };
1227}
1228
1229#[cfg(all(test, target_os = "linux"))]
1232thread_local! {
1233 pub(crate) static FORCE_DELEGATION_FAILURE: std::cell::Cell<bool> =
1234 const { std::cell::Cell::new(false) };
1235}
1236
1237impl Drop for CgroupScope {
1238 fn drop(&mut self) {
1246 if self.path.as_os_str().is_empty() {
1247 return;
1248 }
1249 if !self.member_pids().is_empty() {
1250 let _ = self.kill_all();
1251 self.wait_until_empty(KILL_FALLBACK_DEADLINE);
1252 }
1253 self.procs_fd = None;
1256 #[cfg(target_os = "linux")]
1257 {
1258 self.watcher = None;
1259 }
1260 if let Err(error) = std::fs::remove_dir(&self.path) {
1261 crate::runtime_err!(
1262 "[capsule-runtime] note: could not remove cgroup scope {} ({error}); \
1263 it will be reclaimed once its last task exits",
1264 self.path.display()
1265 );
1266 }
1267 }
1268}
1269
1270#[cfg(target_os = "linux")]
1275#[derive(Debug)]
1276struct ScopeWatcher {
1277 lifeline: std::sync::Mutex<Option<std::os::fd::OwnedFd>>,
1281 pid: libc::pid_t,
1282}
1283
1284#[cfg(target_os = "linux")]
1288#[derive(Clone, Copy)]
1289struct WatcherKill {
1290 kill: libc::c_int,
1291 freeze: libc::c_int,
1292 procs: libc::c_int,
1293}
1294
1295#[cfg(target_os = "linux")]
1296impl ScopeWatcher {
1297 #[allow(unsafe_code)]
1301 fn spawn(scope: &Path) -> std::io::Result<Self> {
1302 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
1303
1304 #[cfg(test)]
1305 let forced = FORCE_KILL_FALLBACK.with(std::cell::Cell::get);
1306 #[cfg(not(test))]
1307 let forced = false;
1308 let kill = if forced {
1309 None
1310 } else {
1311 open_if_present(&scope.join("cgroup.kill"), libc::O_WRONLY)?
1312 };
1313 let (freeze, procs) = match kill {
1314 Some(_) => (None, None),
1315 None => (
1316 open_if_present(&scope.join("cgroup.freeze"), libc::O_WRONLY)?,
1317 Some(open_with(&scope.join("cgroup.procs"), libc::O_RDONLY)?),
1318 ),
1319 };
1320 let devnull = open_with(Path::new("/dev/null"), libc::O_RDWR)?;
1321
1322 let mut ends = [0 as libc::c_int; 2];
1323 if unsafe { libc::pipe2(ends.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
1325 return Err(std::io::Error::last_os_error());
1326 }
1327 let (read_end, write_end) =
1330 unsafe { (OwnedFd::from_raw_fd(ends[0]), OwnedFd::from_raw_fd(ends[1])) };
1331
1332 let raw = |fd: &Option<OwnedFd>| fd.as_ref().map_or(-1, AsRawFd::as_raw_fd);
1333 let plan = WatcherKill {
1334 kill: raw(&kill),
1335 freeze: raw(&freeze),
1336 procs: raw(&procs),
1337 };
1338 let mut keep = [-1 as libc::c_int; 7];
1342 let mut kept = 0;
1343 for fd in [
1344 0,
1345 1,
1346 2,
1347 read_end.as_raw_fd(),
1348 plan.kill,
1349 plan.freeze,
1350 plan.procs,
1351 ] {
1352 if fd >= 0 {
1353 keep[kept] = fd;
1354 kept += 1;
1355 }
1356 }
1357 keep[..kept].sort_unstable();
1358 let devnull_fd = devnull.as_raw_fd();
1359 let read_fd = read_end.as_raw_fd();
1360 let deadline_ns = KILL_FALLBACK_DEADLINE.as_nanos() as i64;
1361
1362 let pid = unsafe { libc::fork() };
1368 if pid < 0 {
1369 return Err(std::io::Error::last_os_error());
1370 }
1371 if pid == 0 {
1372 unsafe { watch(devnull_fd, &keep[..kept], read_fd, plan, deadline_ns) }
1374 }
1375 Ok(Self {
1376 lifeline: std::sync::Mutex::new(Some(write_end)),
1377 pid,
1378 })
1379 }
1380
1381 #[cfg(test)]
1383 fn cut_lifeline(&self) {
1384 self.lifeline
1385 .lock()
1386 .unwrap_or_else(std::sync::PoisonError::into_inner)
1387 .take();
1388 }
1389}
1390
1391#[cfg(target_os = "linux")]
1392impl Drop for ScopeWatcher {
1393 #[allow(unsafe_code)]
1396 fn drop(&mut self) {
1397 self.lifeline
1398 .get_mut()
1399 .unwrap_or_else(std::sync::PoisonError::into_inner)
1400 .take();
1401 let mut status = 0;
1402 loop {
1403 let waited = unsafe { libc::waitpid(self.pid, &mut status, 0) };
1405 if waited >= 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) {
1406 return;
1407 }
1408 }
1409 }
1410}
1411
1412#[cfg(target_os = "linux")]
1414fn open_with(path: &Path, flags: libc::c_int) -> std::io::Result<std::os::fd::OwnedFd> {
1415 use std::os::unix::fs::OpenOptionsExt;
1416
1417 std::fs::OpenOptions::new()
1418 .read(flags & libc::O_ACCMODE != libc::O_WRONLY)
1419 .write(flags & libc::O_ACCMODE != libc::O_RDONLY)
1420 .custom_flags(libc::O_CLOEXEC)
1421 .open(path)
1422 .map(std::os::fd::OwnedFd::from)
1423}
1424
1425#[cfg(target_os = "linux")]
1427fn open_if_present(
1428 path: &Path,
1429 flags: libc::c_int,
1430) -> std::io::Result<Option<std::os::fd::OwnedFd>> {
1431 match open_with(path, flags) {
1432 Ok(fd) => Ok(Some(fd)),
1433 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1434 Err(error) => Err(error),
1435 }
1436}
1437
1438#[cfg(target_os = "linux")]
1447#[allow(unsafe_code)]
1448unsafe fn watch(
1449 devnull: libc::c_int,
1450 keep: &[libc::c_int],
1451 read_fd: libc::c_int,
1452 plan: WatcherKill,
1453 deadline_ns: i64,
1454) -> ! {
1455 unsafe {
1458 for target in 0..3 {
1460 libc::dup2(devnull, target);
1461 }
1462 let mut low: libc::c_uint = 0;
1463 for &fd in keep {
1464 let fd = fd as libc::c_uint;
1465 if fd > low {
1466 close_range(low, fd - 1);
1467 }
1468 low = fd + 1;
1469 }
1470 close_range(low, libc::c_uint::MAX);
1471
1472 libc::setpgid(0, 0);
1473 let mut ignore: libc::sigaction = std::mem::zeroed();
1474 ignore.sa_sigaction = libc::SIG_IGN;
1475 for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGPIPE] {
1476 libc::sigaction(signal, &ignore, std::ptr::null_mut());
1477 }
1478
1479 let mut byte = [0u8; 1];
1480 loop {
1481 let read = libc::read(read_fd, byte.as_mut_ptr().cast(), 1);
1482 if read == 0 {
1483 break;
1484 }
1485 if read < 0 && *libc::__errno_location() != libc::EINTR {
1486 break;
1487 }
1488 }
1489
1490 kill_scope(plan, deadline_ns);
1491 libc::_exit(0)
1492 }
1493}
1494
1495#[cfg(target_os = "linux")]
1502#[allow(unsafe_code)]
1503unsafe fn close_range(low: libc::c_uint, high: libc::c_uint) {
1504 unsafe {
1506 if libc::syscall(libc::SYS_close_range, low, high, 0 as libc::c_uint) == 0 {
1507 return;
1508 }
1509 let mut limit: libc::rlimit = std::mem::zeroed();
1510 let ceiling = if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) == 0 {
1511 limit.rlim_cur.min(libc::c_uint::MAX as libc::rlim_t) as libc::c_uint
1512 } else {
1513 1 << 20
1514 };
1515 let mut fd = low;
1516 while fd <= high && fd < ceiling {
1517 libc::close(fd as libc::c_int);
1518 fd += 1;
1519 }
1520 }
1521}
1522
1523#[cfg(target_os = "linux")]
1530#[allow(unsafe_code)]
1531unsafe fn kill_scope(plan: WatcherKill, deadline_ns: i64) {
1532 unsafe {
1534 if plan.kill >= 0 {
1535 write_control(plan.kill, b"1");
1536 return;
1537 }
1538 let start = monotonic_ns();
1539 loop {
1540 if plan.freeze >= 0 {
1541 write_control(plan.freeze, b"1");
1542 }
1543 let signalled = signal_listed(plan.procs);
1544 if plan.freeze >= 0 {
1545 write_control(plan.freeze, b"0");
1546 }
1547 if signalled == 0 || monotonic_ns() - start >= deadline_ns {
1548 return;
1549 }
1550 let pause = libc::timespec {
1551 tv_sec: 0,
1552 tv_nsec: KILL_POLL_INTERVAL.as_nanos() as libc::c_long,
1553 };
1554 libc::nanosleep(&pause, std::ptr::null_mut());
1555 }
1556 }
1557}
1558
1559#[cfg(target_os = "linux")]
1565#[allow(unsafe_code)]
1566unsafe fn write_control(fd: libc::c_int, value: &[u8]) {
1567 unsafe {
1569 libc::lseek(fd, 0, libc::SEEK_SET);
1570 libc::write(fd, value.as_ptr().cast(), value.len());
1571 }
1572}
1573
1574#[cfg(target_os = "linux")]
1581#[allow(unsafe_code)]
1582unsafe fn signal_listed(procs: libc::c_int) -> usize {
1583 unsafe {
1585 if procs < 0 || libc::lseek(procs, 0, libc::SEEK_SET) < 0 {
1586 return 0;
1587 }
1588 let mut buf = [0u8; 512];
1589 let mut pid: libc::pid_t = 0;
1590 let mut digits = false;
1591 let mut signalled = 0;
1592 loop {
1593 let read = libc::read(procs, buf.as_mut_ptr().cast(), buf.len());
1594 if read < 0 && *libc::__errno_location() == libc::EINTR {
1595 continue;
1596 }
1597 if read <= 0 {
1598 break;
1599 }
1600 for &byte in &buf[..read as usize] {
1601 if byte.is_ascii_digit() {
1602 pid = pid
1603 .saturating_mul(10)
1604 .saturating_add(libc::pid_t::from(byte - b'0'));
1605 digits = true;
1606 } else {
1607 if digits && pid > 0 {
1608 libc::kill(pid, libc::SIGKILL);
1609 signalled += 1;
1610 }
1611 pid = 0;
1612 digits = false;
1613 }
1614 }
1615 }
1616 if digits && pid > 0 {
1617 libc::kill(pid, libc::SIGKILL);
1618 signalled += 1;
1619 }
1620 signalled
1621 }
1622}
1623
1624#[cfg(target_os = "linux")]
1630#[allow(unsafe_code)]
1631unsafe fn monotonic_ns() -> i64 {
1632 unsafe {
1634 let mut now: libc::timespec = std::mem::zeroed();
1635 libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now);
1636 now.tv_sec as i64 * 1_000_000_000 + now.tv_nsec as i64
1637 }
1638}
1639
1640#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1645fn format_pid(pid: i32, buf: &mut [u8; 24]) -> usize {
1646 let mut digits = [0u8; 20];
1647 let mut count = 0;
1648 let mut value = pid.unsigned_abs() as u64;
1649 if value == 0 {
1650 digits[0] = b'0';
1651 count = 1;
1652 }
1653 while value > 0 {
1654 digits[count] = b'0' + (value % 10) as u8;
1655 value /= 10;
1656 count += 1;
1657 }
1658
1659 let mut written = 0;
1660 for index in (0..count).rev() {
1661 buf[written] = digits[index];
1662 written += 1;
1663 }
1664 buf[written] = b'\n';
1665 written + 1
1666}
1667
1668#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1673fn read_event_counter(path: &Path, key: &str) -> u64 {
1674 let Ok(contents) = std::fs::read_to_string(path) else {
1675 return 0;
1676 };
1677 parse_event_counter(&contents, key)
1678}
1679
1680#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1682fn parse_event_counter(contents: &str, key: &str) -> u64 {
1683 for line in contents.lines() {
1684 let mut parts = line.split_whitespace();
1685 if parts.next() == Some(key) {
1686 return parts
1687 .next()
1688 .and_then(|value| value.parse().ok())
1689 .unwrap_or(0);
1690 }
1691 }
1692 0
1693}
1694
1695#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1707fn scope_dir_name(session_id: &str) -> String {
1708 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1709 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1710
1711 let sanitized: String = session_id
1712 .chars()
1713 .map(|c| {
1714 if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
1715 c
1716 } else {
1717 '_'
1718 }
1719 })
1720 .collect();
1721 let pid = std::process::id();
1722 if sanitized.is_empty() {
1723 format!("murmur-{pid}-{serial}")
1724 } else {
1725 format!("murmur-{sanitized}-{pid}-{serial}")
1726 }
1727}
1728
1729#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1735fn parse_unified_cgroup_path(contents: &str) -> Option<String> {
1736 for line in contents.lines() {
1737 let mut parts = line.splitn(3, ':');
1738 let (Some("0"), Some(""), Some(path)) = (parts.next(), parts.next(), parts.next()) else {
1739 continue;
1740 };
1741 if path.starts_with('/') {
1742 return Some(path.to_string());
1743 }
1744 }
1745 None
1746}
1747
1748#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1751fn parse_cgroup2_mount(contents: &str) -> String {
1752 for line in contents.lines() {
1753 let mut parts = line.split_whitespace();
1754 let (Some(_source), Some(target), Some(fstype)) =
1755 (parts.next(), parts.next(), parts.next())
1756 else {
1757 continue;
1758 };
1759 if fstype == "cgroup2" {
1760 return target.to_string();
1761 }
1762 }
1763 DEFAULT_CGROUP2_MOUNT.to_string()
1764}
1765
1766#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1768fn missing_controllers(subtree_control: &str, wanted: &[&str]) -> Vec<String> {
1769 let enabled: Vec<&str> = subtree_control.split_whitespace().collect();
1770 wanted
1771 .iter()
1772 .filter(|controller| !enabled.contains(*controller))
1773 .map(|controller| (*controller).to_string())
1774 .collect()
1775}
1776
1777#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1780fn device_major_minor(dev: u64) -> (u64, u64) {
1781 let major = ((dev >> 8) & 0xfff) | ((dev >> 32) & !0xfff);
1782 let minor = (dev & 0xff) | ((dev >> 12) & !0xff);
1783 (major, minor)
1784}
1785
1786#[cfg(target_os = "linux")]
1787impl CgroupScope {
1788 fn create(
1795 limits: &[ScopeLimit],
1796 io: IoMaxWrite,
1797 session_id: &str,
1798 workdir: &Path,
1799 ) -> Result<(Self, IoMaxReport), String> {
1800 let base = delegated_base()?;
1801
1802 let path = base.join(scope_dir_name(session_id));
1803 create_scope_dir(&path).map_err(|error| scope_dir_refusal(&path, &error))?;
1804
1805 let scope = Self {
1806 path,
1807 procs_fd: None,
1808 occupancy: Arc::default(),
1809 watcher: None,
1810 };
1811
1812 for limit in limits {
1813 scope.write_limit(limit)?;
1814 }
1815 let io_max = match io {
1816 IoMaxWrite::Declared(bytes_per_sec) => scope.apply_io_max(bytes_per_sec, workdir),
1817 IoMaxWrite::Undeclared(bytes_per_sec) => {
1818 IoMaxReport::not_required(bytes_per_sec, UNCONFINED_IO_UNDECLARED_REASON)
1819 }
1820 };
1821
1822 let procs_fd = open_write_only(&scope.path.join("cgroup.procs")).map_err(|error| {
1825 format!(
1826 "could not open {}/cgroup.procs for subprocess placement: {error}",
1827 scope.path.display()
1828 )
1829 })?;
1830
1831 let watcher = ScopeWatcher::spawn(&scope.path).map_err(|error| {
1832 format!(
1833 "could not start the watcher that kills {} if the runtime dies: {error}",
1834 scope.path.display()
1835 )
1836 })?;
1837
1838 Ok((
1839 Self {
1840 path: scope.take_path(),
1841 procs_fd: Some(procs_fd),
1842 occupancy: Arc::default(),
1843 watcher: Some(watcher),
1844 },
1845 io_max,
1846 ))
1847 }
1848
1849 fn take_path(mut self) -> PathBuf {
1852 let path = std::mem::take(&mut self.path);
1853 path
1855 }
1856
1857 fn write_limit(&self, limit: &ScopeLimit) -> Result<(), String> {
1860 let path = self.path.join(limit.file);
1861 let value = &limit.value;
1862 match write_existing(&path, &format!("{value}\n")) {
1863 Err(error) if limit.absent_ok && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1864 written => written
1865 .map_err(|error| format!("could not write {value} to {}: {error}", path.display())),
1866 }
1867 }
1868
1869 fn apply_io_max(&self, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1881 write_io_max(&self.path, bytes_per_sec, workdir)
1882 }
1883}
1884
1885#[cfg(target_os = "linux")]
1895fn write_io_max(scope_path: &Path, bytes_per_sec: u64, workdir: &Path) -> IoMaxReport {
1896 let (major, minor) = match io_max_device(workdir) {
1897 Ok(device) => device,
1898 Err(reason) => return IoMaxReport::unavailable(bytes_per_sec, reason),
1899 };
1900 let value = format!("{major}:{minor} rbps={bytes_per_sec} wbps={bytes_per_sec}");
1901 match std::fs::write(scope_path.join("io.max"), format!("{value}\n")) {
1902 Ok(()) => IoMaxReport::enforced(bytes_per_sec),
1903 Err(error) => IoMaxReport::unavailable(
1909 bytes_per_sec,
1910 format!("writing `{value}` to the scope's io.max: {error}"),
1911 ),
1912 }
1913}
1914
1915#[cfg(target_os = "linux")]
1933fn io_max_device(path: &Path) -> Result<(u64, u64), String> {
1934 use std::os::linux::fs::MetadataExt;
1935
1936 let Some(existing) = nearest_existing_ancestor(path) else {
1937 return Err(format!(
1938 "could not stat {} or any ancestor of it to find its backing device",
1939 path.display()
1940 ));
1941 };
1942 let metadata = existing.metadata().map_err(|error| {
1943 format!(
1944 "could not stat {} to find its backing device: {error}",
1945 existing.display()
1946 )
1947 })?;
1948 let (major, minor) = device_major_minor(metadata.st_dev());
1949 let (major, minor) = if major == 0 {
1950 mount_source_device(existing)?
1951 } else {
1952 (major, minor)
1953 };
1954 Ok(whole_disk_device(major, minor))
1955}
1956
1957#[cfg(target_os = "linux")]
1963fn mount_source_device(path: &Path) -> Result<(u64, u64), String> {
1964 use std::os::unix::fs::{FileTypeExt, MetadataExt};
1965
1966 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1967 let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(|error| {
1968 format!(
1969 "reading /proc/self/mountinfo to find the device behind {}: {error}",
1970 resolved.display()
1971 )
1972 })?;
1973 let Some(mount) = mount_covering(&mountinfo, &resolved) else {
1974 return Err(format!(
1975 "no mount in /proc/self/mountinfo covers {}, so its backing device is unknown",
1976 resolved.display()
1977 ));
1978 };
1979 match std::fs::metadata(&mount.source) {
1980 Ok(metadata) if metadata.file_type().is_block_device() => {
1981 Ok(device_major_minor(metadata.rdev()))
1982 }
1983 _ => Err(format!(
1984 "the filesystem mounted at {} is backed by `{}`, which is not a block device, so no \
1985 io.max ceiling can name one",
1986 mount.mount_point.display(),
1987 mount.source.display()
1988 )),
1989 }
1990}
1991
1992#[cfg(target_os = "linux")]
2000fn whole_disk_device(major: u64, minor: u64) -> (u64, u64) {
2001 let device = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
2002 if !device.join("partition").exists() {
2003 return (major, minor);
2004 }
2005 std::fs::read_to_string(device.join("../dev"))
2006 .ok()
2007 .and_then(|contents| parse_dev_line(&contents))
2008 .unwrap_or((major, minor))
2009}
2010
2011#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2013fn parse_dev_line(contents: &str) -> Option<(u64, u64)> {
2014 let (major, minor) = contents.trim().split_once(':')?;
2015 Some((major.parse().ok()?, minor.parse().ok()?))
2016}
2017
2018#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2021#[derive(Debug, PartialEq, Eq)]
2022struct MountEntry {
2023 mount_point: PathBuf,
2025 source: PathBuf,
2028}
2029
2030#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2035fn mount_covering(mountinfo: &str, path: &Path) -> Option<MountEntry> {
2036 let mut best: Option<(usize, MountEntry)> = None;
2037 for line in mountinfo.lines() {
2038 let Some(entry) = parse_mount_entry(line) else {
2039 continue;
2040 };
2041 if !path.starts_with(&entry.mount_point) {
2042 continue;
2043 }
2044 let depth = entry.mount_point.components().count();
2045 if best.as_ref().is_none_or(|(best, _)| depth >= *best) {
2046 best = Some((depth, entry));
2047 }
2048 }
2049 best.map(|(_, entry)| entry)
2050}
2051
2052#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2057fn parse_mount_entry(line: &str) -> Option<MountEntry> {
2058 let (mounted, backing) = line.split_once(" - ")?;
2059 let mount_point = mounted.split_whitespace().nth(4)?;
2060 let source = backing.split_whitespace().nth(1)?;
2061 Some(MountEntry {
2062 mount_point: unescape_octal(mount_point),
2063 source: unescape_octal(source),
2064 })
2065}
2066
2067#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2070fn unescape_octal(field: &str) -> PathBuf {
2071 use std::os::unix::ffi::OsStringExt;
2072
2073 let bytes = field.as_bytes();
2074 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
2075 let mut index = 0;
2076 while index < bytes.len() {
2077 let escape = bytes[index] == b'\\'
2078 && index + 3 < bytes.len()
2079 && bytes[index + 1..index + 4]
2080 .iter()
2081 .all(|byte| (b'0'..=b'7').contains(byte));
2082 if escape {
2083 let digits = &field[index + 1..index + 4];
2084 out.push(u8::from_str_radix(digits, 8).expect("three octal digits"));
2085 index += 4;
2086 } else {
2087 out.push(bytes[index]);
2088 index += 1;
2089 }
2090 }
2091 PathBuf::from(std::ffi::OsString::from_vec(out))
2092}
2093
2094#[cfg(target_os = "linux")]
2100fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
2101 let mut candidate = Some(path);
2102 while let Some(current) = candidate {
2103 if current.exists() {
2104 return Some(current);
2105 }
2106 candidate = current.parent();
2107 }
2108 None
2109}
2110
2111#[cfg(all(test, target_os = "linux"))]
2112pub(crate) static DELEGATED_BASE_RESOLUTION_SPAWNS: std::sync::atomic::AtomicUsize =
2113 std::sync::atomic::AtomicUsize::new(0);
2114
2115pub(crate) fn resolve_delegated_base_in_background() {
2128 #[cfg(target_os = "linux")]
2129 {
2130 #[cfg(test)]
2131 DELEGATED_BASE_RESOLUTION_SPAWNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2132 let _ = std::thread::Builder::new()
2133 .name("murmur-cgroup-base".to_string())
2134 .spawn(|| {
2135 let _ = delegated_base();
2136 });
2137 }
2138}
2139
2140#[cfg(target_os = "linux")]
2154fn delegated_base() -> Result<PathBuf, CgroupError> {
2155 static BASE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2156
2157 let mut cached = BASE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
2160 if let Some(base) = cached.as_ref() {
2161 return Ok(base.clone());
2162 }
2163
2164 move_self_into_own_systemd_scope();
2167
2168 let base = probe_delegation()?;
2169 enable_controllers(&base)?;
2170 *cached = Some(base.clone());
2171 Ok(base)
2172}
2173
2174#[cfg(target_os = "linux")]
2184fn move_self_into_own_systemd_scope() {
2185 static ATTEMPTED: std::sync::Once = std::sync::Once::new();
2186 ATTEMPTED.call_once(|| {
2187 let _ = request_transient_scope(&transient_scope_unit_name());
2188 });
2189}
2190
2191#[cfg(target_os = "linux")]
2198fn transient_scope_unit_name() -> String {
2199 static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2200 let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2201 format!("murmur-{}-{serial}.scope", std::process::id())
2202}
2203
2204#[cfg(target_os = "linux")]
2207fn request_transient_scope(unit: &str) -> Result<(), String> {
2208 let owned_unit = unit.to_string();
2211 std::thread::Builder::new()
2212 .name("murmur-cgroup-scope".to_string())
2213 .spawn(move || start_transient_scope(&owned_unit))
2214 .map_err(|error| format!("could not spawn the D-Bus thread: {error}"))?
2215 .join()
2216 .map_err(|_| "the D-Bus thread panicked".to_string())??;
2217
2218 await_scope_membership(unit)
2219}
2220
2221#[cfg(target_os = "linux")]
2237fn start_transient_scope(unit: &str) -> Result<(), String> {
2238 use zbus::Variant;
2239
2240 let properties: Vec<(&str, Variant<'_>)> = vec![
2241 (
2242 "Description",
2243 Variant::from("Murmur capsule subprocess scope"),
2244 ),
2245 ("PIDs", Variant::from(vec![std::process::id()])),
2246 ("Delegate", Variant::from(true)),
2247 ("CollectMode", Variant::from("inactive-or-failed")),
2248 ];
2249 let aux: Vec<(&str, Vec<(&str, Variant<'_>)>)> = Vec::new();
2251
2252 zbus::block_on(async {
2253 let connection = zbus::Connection::session()
2254 .await
2255 .map_err(|error| format!("no systemd session bus: {error}"))?;
2256 connection
2257 .call_method(
2258 Some("org.freedesktop.systemd1"),
2259 "/org/freedesktop/systemd1",
2260 Some("org.freedesktop.systemd1.Manager"),
2261 "StartTransientUnit",
2262 &(unit, "fail", properties, aux),
2265 )
2266 .await
2267 .map(|_| ())
2268 .map_err(|error| format!("systemd refused to start {unit}: {error}"))
2269 })
2270}
2271
2272#[cfg(target_os = "linux")]
2286fn await_scope_membership(unit: &str) -> Result<(), String> {
2287 let deadline = std::time::Instant::now() + Duration::from_secs(2);
2288 loop {
2289 let current = std::fs::read_to_string("/proc/self/cgroup").unwrap_or_default();
2290 if let Some(path) = parse_unified_cgroup_path(¤t) {
2291 if path.rsplit('/').next() == Some(unit) {
2292 return Ok(());
2293 }
2294 }
2295 if std::time::Instant::now() >= deadline {
2296 return Err(format!(
2297 "systemd accepted the request for {unit} but this process is not in it"
2298 ));
2299 }
2300 std::thread::sleep(Duration::from_millis(1));
2301 }
2302}
2303
2304#[cfg(target_os = "linux")]
2312fn probe_delegation() -> Result<PathBuf, CgroupError> {
2313 let self_cgroup = std::fs::read_to_string("/proc/self/cgroup").map_err(|error| {
2314 CgroupError::io(format!("could not read /proc/self/cgroup: {error}"), &error)
2315 })?;
2316 let relative = parse_unified_cgroup_path(&self_cgroup).ok_or_else(|| {
2317 "this host has no cgroup v2 unified hierarchy (no `0::` line in /proc/self/cgroup); \
2318 cgroup v1-only and hybrid hosts cannot be delegated a v2 scope"
2319 .to_string()
2320 })?;
2321
2322 let mount = std::fs::read_to_string("/proc/mounts")
2323 .map(|contents| parse_cgroup2_mount(&contents))
2324 .unwrap_or_else(|_| DEFAULT_CGROUP2_MOUNT.to_string());
2325
2326 let base = ascend_out_of_supervisor_leaves(
2327 PathBuf::from(mount).join(relative.trim_start_matches('/')),
2328 );
2329 if !base.is_dir() {
2330 return Err(format!(
2331 "delegated cgroup directory {} does not exist",
2332 base.display()
2333 )
2334 .into());
2335 }
2336
2337 let controllers =
2338 std::fs::read_to_string(base.join("cgroup.controllers")).map_err(|error| {
2339 CgroupError::io(
2340 format!(
2341 "could not read {}/cgroup.controllers: {error}",
2342 base.display()
2343 ),
2344 &error,
2345 )
2346 })?;
2347 let unavailable = missing_controllers(&controllers, REQUIRED_CONTROLLERS);
2348 if !unavailable.is_empty() {
2349 return Err(format!(
2350 "cgroup controllers [{}] are not delegated to {}; the unit `mur` runs under needs \
2351 `Delegate=yes` (or `Delegate=memory pids cpu io`)",
2352 unavailable.join(", "),
2353 base.display()
2354 )
2355 .into());
2356 }
2357
2358 let probe_dir = base.join(format!("murmur-delegation-probe-{}", std::process::id()));
2362 let _ = std::fs::remove_dir(&probe_dir);
2363 std::fs::create_dir(&probe_dir).map_err(|error| {
2364 CgroupError::io(
2365 format!(
2366 "cannot create a child cgroup under {} ({error}); the unit `mur` runs under needs \
2367 `Delegate=yes` for memory, pids, cpu and io",
2368 base.display()
2369 ),
2370 &error,
2371 )
2372 })?;
2373 let _ = std::fs::remove_dir(&probe_dir);
2374
2375 Ok(base)
2376}
2377
2378#[cfg(target_os = "linux")]
2385fn enable_controllers(base: &Path) -> Result<(), CgroupError> {
2386 let subtree_control = base.join("cgroup.subtree_control");
2387 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
2388
2389 let available = std::fs::read_to_string(base.join("cgroup.controllers")).unwrap_or_default();
2392 let mut wanted: Vec<&str> = REQUIRED_CONTROLLERS.to_vec();
2393 if available.split_whitespace().any(|c| c == "io") {
2394 wanted.push("io");
2395 }
2396
2397 let missing = missing_controllers(¤t, &wanted);
2398 if missing.is_empty() {
2399 return Ok(());
2400 }
2401 let directive = missing
2402 .iter()
2403 .map(|controller| format!("+{controller}"))
2404 .collect::<Vec<_>>()
2405 .join(" ");
2406
2407 if std::fs::write(&subtree_control, format!("{directive}\n")).is_ok() {
2408 return Ok(());
2409 }
2410
2411 move_self_to_supervisor_leaf(base)?;
2412
2413 let deadline = std::time::Instant::now() + Duration::from_secs(2);
2424 let mut last_error;
2427 loop {
2428 let current = std::fs::read_to_string(&subtree_control).unwrap_or_default();
2429 if missing_controllers(¤t, &wanted).is_empty() {
2430 return Ok(());
2431 }
2432 match std::fs::write(&subtree_control, format!("{directive}\n")) {
2433 Ok(()) => return Ok(()),
2434 Err(error) => last_error = Some(error),
2435 }
2436 if std::time::Instant::now() >= deadline {
2437 break;
2438 }
2439 std::thread::sleep(Duration::from_millis(20));
2440 }
2441
2442 Err(CgroupError {
2443 errno: last_error.as_ref().and_then(std::io::Error::raw_os_error),
2444 message: format!(
2445 "could not enable controllers [{}] in {} ({}); the unit `mur` runs under needs \
2446 `Delegate=yes` for memory, pids, cpu and io",
2447 missing.join(", "),
2448 subtree_control.display(),
2449 last_error.map_or_else(|| "still occupied".to_string(), |error| error.to_string()),
2450 ),
2451 })
2452}
2453
2454#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2465fn ascend_out_of_supervisor_leaves(mut base: PathBuf) -> PathBuf {
2466 while base.file_name().and_then(|name| name.to_str()) == Some(SUPERVISOR_LEAF) {
2467 base.pop();
2468 }
2469 base
2470}
2471
2472#[cfg(target_os = "linux")]
2474fn move_self_to_supervisor_leaf(base: &Path) -> Result<(), CgroupError> {
2475 let leaf = base.join(SUPERVISOR_LEAF);
2476 if let Err(error) = std::fs::create_dir(&leaf) {
2477 if error.kind() != std::io::ErrorKind::AlreadyExists {
2478 return Err(CgroupError::io(
2479 format!(
2480 "could not create {} to vacate the delegated cgroup: {error}",
2481 leaf.display()
2482 ),
2483 &error,
2484 ));
2485 }
2486 }
2487 std::fs::write(
2488 leaf.join("cgroup.procs"),
2489 format!("{}\n", std::process::id()),
2490 )
2491 .map_err(|error| {
2492 CgroupError::io(
2493 format!(
2494 "could not move this process into {} to vacate the delegated cgroup: {error}",
2495 leaf.display()
2496 ),
2497 &error,
2498 )
2499 })
2500}
2501
2502#[cfg(target_os = "linux")]
2508#[allow(unsafe_code)]
2509fn open_write_only(path: &Path) -> std::io::Result<std::os::fd::OwnedFd> {
2510 use std::os::unix::fs::OpenOptionsExt;
2511
2512 std::fs::OpenOptions::new()
2513 .write(true)
2514 .custom_flags(libc::O_CLOEXEC)
2515 .open(path)
2516 .map(std::os::fd::OwnedFd::from)
2517}
2518
2519pub fn cgroup_delegation_available() -> bool {
2534 #[cfg(not(target_os = "linux"))]
2535 {
2536 true
2537 }
2538
2539 #[cfg(target_os = "linux")]
2540 {
2541 delegated_base().is_ok()
2547 }
2548}
2549
2550#[must_use]
2559pub fn io_max_device_available(path: &Path) -> bool {
2560 #[cfg(not(target_os = "linux"))]
2561 {
2562 let _ = path;
2563 false
2564 }
2565
2566 #[cfg(target_os = "linux")]
2567 {
2568 io_max_device(path).is_ok()
2569 }
2570}
2571
2572pub fn skip_without_host_support(test_name: &str) -> bool {
2580 if crate::network_namespace::skip_without_egress_namespace(test_name) {
2581 return true;
2582 }
2583 if !cgroup_delegation_available() {
2584 crate::runtime_err!(
2585 "[SKIP-HOST] {test_name}: this host cannot delegate a cgroup v2 scope, so a capsule \
2586 that can spawn native subprocesses refuses to launch with E-RUN-012 before anything \
2587 this test observes happens -- see {}",
2588 murmur_artifact::docs_reference_url!("resource-limits/#platform-behavior")
2589 );
2590 return true;
2591 }
2592 false
2593}
2594
2595#[cfg(test)]
2596mod tests {
2597 use super::*;
2598 use crate::types::CapabilityPolicy;
2599
2600 #[test]
2604 fn io_max_status_wire_names_are_kebab_case() {
2605 let spellings: Vec<String> = [
2606 IoMaxStatus::Enforced,
2607 IoMaxStatus::Unavailable,
2608 IoMaxStatus::NotRequired,
2609 IoMaxStatus::NotProbed,
2610 ]
2611 .into_iter()
2612 .map(|status| {
2613 serde_json::to_value(status)
2614 .unwrap()
2615 .as_str()
2616 .unwrap()
2617 .to_string()
2618 })
2619 .collect();
2620 assert_eq!(
2621 spellings,
2622 vec!["enforced", "unavailable", "not-required", "not-probed"]
2623 );
2624 }
2625
2626 #[test]
2629 fn an_unfilled_report_claims_nothing() {
2630 let report = IoMaxReport::default();
2631 assert_eq!(report.status, IoMaxStatus::NotProbed);
2632 assert_eq!(report.declared_bytes_per_sec, 0);
2633 assert_eq!(report.reason, None);
2634 assert_eq!(io_max_warning(&report, false), None);
2635 }
2636
2637 #[test]
2639 fn only_a_failed_write_omits_nothing_and_a_successful_one_explains_nothing() {
2640 let enforced = serde_json::to_value(IoMaxReport::enforced(1_000)).unwrap();
2641 assert_eq!(enforced["status"], "enforced");
2642 assert_eq!(enforced["declared_bytes_per_sec"], 1_000);
2643 assert!(
2644 enforced.get("reason").is_none(),
2645 "an applied ceiling has nothing to explain: {enforced}"
2646 );
2647
2648 let unavailable =
2649 serde_json::to_value(IoMaxReport::unavailable(1_000, "no io controller")).unwrap();
2650 assert_eq!(unavailable["status"], "unavailable");
2651 assert_eq!(unavailable["reason"], "no io controller");
2652 }
2653
2654 #[test]
2658 fn the_warning_fires_only_where_a_declared_ceiling_did_not_apply() {
2659 assert_eq!(io_max_warning(&IoMaxReport::enforced(4_096), true), None);
2660 assert_eq!(
2661 io_max_warning(&IoMaxReport::not_required(4_096, NO_SCOPE_REASON), true),
2662 None
2663 );
2664
2665 let unavailable = IoMaxReport::unavailable(4_096, "device 0:24 is not a block device");
2666 let (code, message) =
2667 io_max_warning(&unavailable, true).expect("an unapplied ceiling must be reported");
2668 assert_eq!(code, murmur_artifact::W_SEC_021);
2669 assert!(message.contains("4096 bytes/s"), "message was: {message}");
2670 assert!(
2671 message.contains("device 0:24 is not a block device"),
2672 "message was: {message}"
2673 );
2674 assert!(
2675 message.contains("memory.max, pids.max and cpu.max are still enforced on the scope"),
2676 "message was: {message}"
2677 );
2678 }
2679
2680 #[test]
2683 fn the_warning_names_cpu_max_only_when_a_cpu_cap_is_set() {
2684 let unavailable = IoMaxReport::unavailable(4_096, "no io controller");
2685 let (_, message) =
2686 io_max_warning(&unavailable, false).expect("an unapplied ceiling must be reported");
2687 assert!(
2688 message.contains("memory.max and pids.max are still enforced on the scope"),
2689 "message was: {message}"
2690 );
2691 assert!(!message.contains("cpu.max"), "message was: {message}");
2692 }
2693
2694 #[cfg(target_os = "linux")]
2697 #[test]
2698 fn the_device_is_resolved_from_the_nearest_existing_ancestor() {
2699 let temp = tempfile::tempdir().unwrap();
2700 let missing = temp
2701 .path()
2702 .join("workdir")
2703 .join("does")
2704 .join("not")
2705 .join("exist");
2706 assert_eq!(
2707 nearest_existing_ancestor(&missing),
2708 Some(temp.path()),
2709 "an ancestor of a tempdir always exists"
2710 );
2711 assert_eq!(
2712 io_max_device(&missing).ok(),
2713 io_max_device(temp.path()).ok(),
2714 "a path that does not exist yet must resolve to its filesystem's device"
2715 );
2716 assert!(!missing.exists(), "resolving a device must create nothing");
2717 }
2718
2719 #[test]
2722 fn the_covering_mount_is_the_deepest_one() {
2723 let mountinfo = "\
272425 30 0:29 / / rw,relatime shared:1 - btrfs /dev/nvme0n1p5 rw,subvol=/
272526 25 0:31 / /dev/shm rw,nosuid shared:2 - tmpfs tmpfs rw,inode64
272627 25 0:41 / /space rw,relatime shared:3 - btrfs /dev/nvme0n1p7 rw,subvol=/
2727";
2728 let covering = |path: &str| mount_covering(mountinfo, Path::new(path)).unwrap();
2729
2730 assert_eq!(
2731 covering("/space/project/workdir").source,
2732 PathBuf::from("/dev/nvme0n1p7")
2733 );
2734 assert_eq!(covering("/dev/shm/project").source, PathBuf::from("tmpfs"));
2735 assert_eq!(
2736 covering("/home/someone").source,
2737 PathBuf::from("/dev/nvme0n1p5"),
2738 "a path under no nested mount belongs to the root filesystem"
2739 );
2740 assert_eq!(
2741 covering("/spaceship").source,
2742 PathBuf::from("/dev/nvme0n1p5"),
2743 "mount points match whole path components, not string prefixes"
2744 );
2745 }
2746
2747 #[test]
2750 fn a_mount_over_another_at_the_same_point_wins() {
2751 let mountinfo = "\
275225 30 0:29 / / rw shared:1 - ext4 /dev/sda1 rw
275326 25 0:31 / /mnt rw shared:2 - ext4 /dev/sdb1 rw
275427 25 0:32 / /mnt rw shared:3 - ext4 /dev/sdc1 rw
2755";
2756 assert_eq!(
2757 mount_covering(mountinfo, Path::new("/mnt/data"))
2758 .unwrap()
2759 .source,
2760 PathBuf::from("/dev/sdc1")
2761 );
2762 }
2763
2764 #[test]
2767 fn mount_point_escapes_are_decoded() {
2768 let mountinfo = "\
276925 30 0:29 / /media/a\\040disk rw shared:1 - ext4 /dev/sdb1 rw
2770";
2771 let entry = mount_covering(mountinfo, Path::new("/media/a disk/project")).unwrap();
2772 assert_eq!(entry.mount_point, PathBuf::from("/media/a disk"));
2773 assert_eq!(entry.source, PathBuf::from("/dev/sdb1"));
2774 assert_eq!(unescape_octal("no\\134escape"), PathBuf::from("no\\escape"));
2775 assert_eq!(
2776 unescape_octal("\\04"),
2777 PathBuf::from("\\04"),
2778 "a truncated escape is not an escape"
2779 );
2780 }
2781
2782 #[test]
2784 fn sysfs_dev_attributes_are_read_as_major_minor() {
2785 assert_eq!(parse_dev_line("259:0\n"), Some((259, 0)));
2786 assert_eq!(parse_dev_line(""), None);
2787 assert_eq!(parse_dev_line("259"), None);
2788 }
2789
2790 #[cfg(target_os = "linux")]
2794 #[test]
2795 fn the_resolved_device_is_never_a_partition() {
2796 let Ok((major, minor)) = io_max_device(Path::new(".")) else {
2797 return;
2800 };
2801 let sysfs = PathBuf::from(format!("/sys/dev/block/{major}:{minor}"));
2802 assert!(
2803 !sysfs.join("partition").exists(),
2804 "io.max was given the partition {major}:{minor}, which the block layer refuses"
2805 );
2806 }
2807
2808 #[cfg(target_os = "linux")]
2811 #[test]
2812 fn probing_io_max_creates_nothing_that_survives_the_call() {
2813 if skip_without_host_support("probing_io_max_creates_nothing_that_survives_the_call") {
2814 return;
2815 }
2816 let temp = tempfile::tempdir().unwrap();
2817 let probe = probe_scope(
2818 true,
2819 &HostResourceLimits::default(),
2820 &temp.path().join("workdir"),
2821 );
2822 assert_eq!(probe.cgroup_scope, CgroupScopeReport::delegated());
2823 let report = probe.io_max;
2824 assert!(
2825 matches!(
2826 report.status,
2827 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
2828 ),
2829 "a performed probe must report what it performed, got {report:?}"
2830 );
2831
2832 let leftovers: Vec<String> = std::fs::read_dir(delegated_base().expect("a delegated base"))
2836 .unwrap()
2837 .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
2838 .filter(|name| name.contains("explain-scope"))
2839 .collect();
2840 assert!(
2841 leftovers.is_empty(),
2842 "the throwaway cgroup outlived the probe: {leftovers:?}"
2843 );
2844 }
2845
2846 #[test]
2849 fn a_probe_that_is_not_required_reports_what_a_launch_writes() {
2850 let limits = HostResourceLimits::default();
2851 let probe = probe_scope(false, &limits, Path::new("/nonexistent/workdir"));
2852 let prepared = prepare_scope(false, &limits, "s", Path::new("/nonexistent/workdir"))
2853 .expect("no scope is required");
2854 assert_eq!(probe.cgroup_scope, prepared.cgroup_scope);
2855 assert_eq!(probe.io_max, prepared.io_max);
2856 assert_eq!(probe.cgroup_scope.status, CgroupScopeStatus::NotRequired);
2857 assert_eq!(
2858 serde_json::to_value(&probe.cgroup_scope).unwrap(),
2859 serde_json::json!({"status": "not-required", "reason": NO_CGROUP_SCOPE_REASON})
2860 );
2861 }
2862
2863 #[test]
2867 fn the_cgroup_scope_report_serializes_each_status() {
2868 let error = CgroupError::io(
2869 "cannot create a child cgroup under /sys/fs/cgroup/x (Read-only file system (os \
2870 error 30))"
2871 .to_string(),
2872 &std::io::Error::from_raw_os_error(libc::EROFS),
2873 );
2874 assert_eq!(error.to_string(), error.message);
2875 assert_eq!(String::from(error.clone()), error.message);
2876 let unavailable = CgroupScopeReport::unavailable(&error);
2877 assert_eq!(
2878 serde_json::to_value(&unavailable).unwrap(),
2879 serde_json::json!({
2880 "status": "unavailable",
2881 "errno": "EROFS",
2882 "reason": error.message,
2883 })
2884 );
2885 assert_eq!(
2886 render_cgroup_scope(&unavailable),
2887 format!(
2888 "cgroup scope: unavailable (EROFS) \u{2014} {}",
2889 error.message
2890 )
2891 );
2892 let delegated = CgroupScopeReport::delegated();
2893 assert_eq!(
2894 serde_json::to_value(&delegated).unwrap(),
2895 serde_json::json!({"status": "delegated"})
2896 );
2897 assert_eq!(render_cgroup_scope(&delegated), "cgroup scope: delegated");
2898 assert_eq!(
2899 serde_json::to_value(CgroupScopeReport::default()).unwrap(),
2900 serde_json::json!({"status": "not-probed"})
2901 );
2902 let no_errno = CgroupScopeReport::unavailable(&CgroupError::from("no v2".to_string()));
2903 assert_eq!(no_errno.errno, None);
2904 }
2905
2906 #[cfg(target_os = "linux")]
2909 #[test]
2910 fn a_prepared_scope_reports_delegated() {
2911 if skip_without_host_support("a_prepared_scope_reports_delegated") {
2912 return;
2913 }
2914 let temp = tempfile::tempdir().unwrap();
2915 let prepared = prepare_scope(
2916 true,
2917 &HostResourceLimits::default(),
2918 "cgroup-scope-report",
2919 &temp.path().join("workdir"),
2920 )
2921 .expect("a delegated host creates the scope");
2922 assert!(prepared.scope.is_some());
2923 assert_eq!(prepared.cgroup_scope, CgroupScopeReport::delegated());
2924 }
2925
2926 #[test]
2927 fn scope_is_required_by_any_route_to_a_native_subprocess() {
2928 let shell = CapabilityPolicy {
2929 shell_allow: vec!["bash".to_string()],
2930 ..CapabilityPolicy::default()
2931 };
2932 let spawn = CapabilityPolicy {
2933 spawn_allow: vec!["child".to_string()],
2934 ..CapabilityPolicy::default()
2935 };
2936 let install_tool = CapabilityPolicy {
2937 install_tool: vec!["local/tool-*".to_string()],
2938 ..CapabilityPolicy::default()
2939 };
2940 let install_skill = CapabilityPolicy {
2941 install_skill: vec!["local/skill-*".to_string()],
2942 ..CapabilityPolicy::default()
2943 };
2944 let bare = CapabilityPolicy::default();
2945
2946 assert!(requires_process_bounding(&shell, false));
2947 assert!(requires_process_bounding(&spawn, false));
2948 assert!(
2949 requires_process_bounding(&install_tool, false),
2950 "a tool the capsule may pull can be native"
2951 );
2952 assert!(
2953 !requires_process_bounding(&install_skill, false),
2954 "a pulled skill gains no grant and runs no subprocess"
2955 );
2956 for policy in [&shell, &spawn, &install_tool, &install_skill, &bare] {
2957 assert_eq!(
2958 requires_process_bounding(policy, false),
2959 crate::runtime::has_subprocess_route_at_staging(policy),
2960 "{policy:?}"
2961 );
2962 }
2963 assert!(
2964 requires_process_bounding(&bare, true),
2965 "a native artifact alone is enough"
2966 );
2967 assert!(
2968 !requires_process_bounding(&bare, false),
2969 "a WASM-only capsule has no process tree to bound and must not be blocked"
2970 );
2971 }
2972
2973 #[test]
2976 fn an_unconfined_session_requires_a_scope_only_for_a_declared_cgroup_field() {
2977 use murmur_artifact::{ContainmentClass, ResourceCapabilities};
2978 let shell = |containment_floor, declared_resources| crate::types::CapabilityPolicy {
2979 shell_allow: vec!["bash".to_string()],
2980 containment_floor,
2981 declared_resources,
2982 ..Default::default()
2983 };
2984 assert!(requires_process_bounding(
2985 &shell(ContainmentClass::Advisory, ResourceCapabilities::default()),
2986 false
2987 ));
2988 assert!(!requires_process_bounding(
2989 &shell(
2990 ContainmentClass::Unconfined,
2991 ResourceCapabilities::default()
2992 ),
2993 true
2994 ));
2995 assert!(!requires_process_bounding(
2996 &shell(
2997 ContainmentClass::Unconfined,
2998 ResourceCapabilities {
2999 max_open_files: Some(256),
3000 workdir_max_bytes: Some(1 << 20),
3001 ..Default::default()
3002 }
3003 ),
3004 false
3005 ));
3006 for declared in [
3007 ResourceCapabilities {
3008 cgroup_memory_bytes: Some(1 << 30),
3009 ..Default::default()
3010 },
3011 ResourceCapabilities {
3012 cgroup_pids_max: Some(64),
3013 ..Default::default()
3014 },
3015 ResourceCapabilities {
3016 cgroup_cpu_percent: Some(50),
3017 ..Default::default()
3018 },
3019 ResourceCapabilities {
3020 cgroup_io_bytes_per_sec: Some(1 << 20),
3021 ..Default::default()
3022 },
3023 ] {
3024 assert!(
3025 requires_process_bounding(&shell(ContainmentClass::Unconfined, declared), false),
3026 "{declared:?}"
3027 );
3028 let bare = crate::types::CapabilityPolicy {
3029 containment_floor: ContainmentClass::Unconfined,
3030 declared_resources: declared,
3031 ..Default::default()
3032 };
3033 assert!(
3034 !requires_process_bounding(&bare, false),
3035 "no subprocess route, no scope"
3036 );
3037 }
3038
3039 let temp = tempfile::tempdir().unwrap();
3041 let prepared = prepare_policy_scope(
3042 &shell(
3043 ContainmentClass::Unconfined,
3044 ResourceCapabilities::default(),
3045 ),
3046 false,
3047 "ses_unconfined",
3048 temp.path(),
3049 )
3050 .expect("an unconfined session with no cgroup field is never refused");
3051 assert!(prepared.scope.is_none());
3052 assert_eq!(prepared.io_max.status, IoMaxStatus::NotRequired);
3053 assert_eq!(
3054 prepared.io_max.reason.as_deref(),
3055 Some(UNCONFINED_NO_SCOPE_REASON)
3056 );
3057 }
3058
3059 #[test]
3061 fn an_unconfined_scope_writes_only_the_declared_fields() {
3062 use murmur_artifact::ResourceCapabilities;
3063 assert!(declared_scope_limits(&ResourceCapabilities::default()).is_empty());
3064 let files = |declared: ResourceCapabilities| -> Vec<(&str, String)> {
3065 declared_scope_limits(&declared)
3066 .into_iter()
3067 .map(|limit| (limit.file, limit.value))
3068 .collect()
3069 };
3070 assert_eq!(
3071 files(ResourceCapabilities {
3072 cgroup_pids_max: Some(64),
3073 max_open_files: Some(256),
3074 ..Default::default()
3075 }),
3076 vec![("pids.max", "64".to_string())]
3077 );
3078 assert_eq!(
3079 files(ResourceCapabilities {
3080 cgroup_memory_bytes: Some(1024),
3081 cgroup_cpu_percent: Some(50),
3082 ..Default::default()
3083 }),
3084 vec![
3085 ("memory.max", "1024".to_string()),
3086 ("cpu.max", format!("50000 {CPU_MAX_PERIOD_US}")),
3087 ]
3088 );
3089 }
3090
3091 #[test]
3095 fn prepare_scope_returns_none_when_bounding_is_not_required() {
3096 let temp = tempfile::tempdir().unwrap();
3097 let scope = prepare_scope(
3098 false,
3099 &HostResourceLimits::default(),
3100 "ses_test",
3101 temp.path(),
3102 )
3103 .expect("a capsule with no subprocess capability must never be refused");
3104 assert!(scope.scope.is_none());
3105 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
3107 assert!(
3108 scope.io_max.reason.is_some(),
3109 "not-required must say which cause"
3110 );
3111 assert_eq!(io_max_warning(&scope.io_max, false), None);
3112 }
3113
3114 #[cfg(not(target_os = "linux"))]
3117 #[test]
3118 fn prepare_scope_is_never_an_error_off_linux() {
3119 let temp = tempfile::tempdir().unwrap();
3120 let scope = prepare_scope(
3121 true,
3122 &HostResourceLimits::default(),
3123 "ses_test",
3124 temp.path(),
3125 )
3126 .expect("a non-Linux host must not refuse to launch for a missing cgroup");
3127 assert!(scope.scope.is_none());
3128 assert_eq!(scope.io_max.status, IoMaxStatus::NotRequired);
3129 assert!(scope
3130 .io_max
3131 .reason
3132 .as_deref()
3133 .expect("not-required off Linux must say cgroups cannot exist here")
3134 .contains("Linux"));
3135 }
3136
3137 #[cfg(target_os = "linux")]
3142 #[test]
3143 #[allow(clippy::print_stdout)]
3144 fn a_required_scope_is_created_whatever_cgroup_this_process_inherited() {
3145 if skip_without_host_support(
3146 "a_required_scope_is_created_whatever_cgroup_this_process_inherited",
3147 ) {
3148 return;
3149 }
3150 let temp = tempfile::tempdir().unwrap();
3151 let prepared = prepare_scope(
3152 true,
3153 &HostResourceLimits::default(),
3154 "ses_selftest",
3155 temp.path(),
3156 )
3157 .expect("a Linux host with a systemd user session must be able to bound a subprocess tree");
3158 let io_max = prepared.io_max.clone();
3159 let scope = prepared
3160 .scope
3161 .expect("a required scope on Linux is never `None`");
3162
3163 for file in ["memory.max", "pids.max"] {
3164 let contents = std::fs::read_to_string(scope.path().join(file))
3165 .unwrap_or_else(|error| panic!("{file} unreadable in the scope: {error}"));
3166 assert!(!contents.trim().is_empty(), "{file} was left unset");
3167 assert_ne!(contents.trim(), "max", "{file} carries no ceiling");
3168 }
3169 let cpu_max = std::fs::read_to_string(scope.path().join("cpu.max"))
3171 .unwrap_or_else(|error| panic!("cpu.max unreadable in the scope: {error}"));
3172 assert_eq!(cpu_max.trim(), "max 100000");
3173 match std::fs::read_to_string(scope.path().join("memory.swap.max")) {
3174 Ok(contents) => {
3175 assert_eq!(
3176 contents.trim(),
3177 SCOPE_SWAP_MAX,
3178 "memory.swap.max was not bounded"
3179 );
3180 assert_eq!(contents.trim(), "0");
3181 println!(
3182 "memory.swap.max in the created scope reads {}",
3183 contents.trim()
3184 );
3185 }
3186 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3187 assert_eq!(probe_swap_control(), SwapControl::Absent);
3188 println!("this kernel exposes no memory.swap.max; swap is not bounded here");
3189 }
3190 Err(error) => panic!("memory.swap.max unreadable in the scope: {error}"),
3191 }
3192
3193 assert!(
3196 matches!(
3197 io_max.status,
3198 IoMaxStatus::Enforced | IoMaxStatus::Unavailable
3199 ),
3200 "a created scope must report a performed io.max write, got {io_max:?}"
3201 );
3202 assert_eq!(
3203 io_max.declared_bytes_per_sec,
3204 HostResourceLimits::default().cgroup_io_bytes_per_sec
3205 );
3206 if io_max.status == IoMaxStatus::Unavailable {
3207 assert!(io_max.reason.is_some(), "unavailable must carry a reason");
3208 assert!(io_max_warning(&io_max, false).is_some());
3209 } else {
3210 assert_eq!(io_max.reason, None);
3211 assert_eq!(io_max_warning(&io_max, false), None);
3212 }
3213 }
3214
3215 #[cfg(target_os = "linux")]
3217 #[test]
3218 fn a_declared_cpu_cap_is_written_to_the_scope() {
3219 if skip_without_host_support("a_declared_cpu_cap_is_written_to_the_scope") {
3220 return;
3221 }
3222 let temp = tempfile::tempdir().unwrap();
3223 let limits = HostResourceLimits {
3224 cgroup_cpu_percent: Some(50),
3225 ..HostResourceLimits::default()
3226 };
3227 let scope = prepare_scope(true, &limits, "ses_cpucap", temp.path())
3228 .expect(
3229 "a Linux host with a systemd user session must be able to bound a subprocess tree",
3230 )
3231 .scope
3232 .expect("a required scope on Linux is never `None`");
3233 let cpu_max = std::fs::read_to_string(scope.path().join("cpu.max"))
3234 .unwrap_or_else(|error| panic!("cpu.max unreadable in the scope: {error}"));
3235 assert_eq!(cpu_max.trim(), "50000 100000");
3236 }
3237
3238 #[cfg(target_os = "linux")]
3242 #[test]
3243 #[allow(clippy::print_stdout)]
3244 fn killing_a_scope_without_cgroup_kill_empties_it() {
3245 if skip_without_host_support("killing_a_scope_without_cgroup_kill_empties_it") {
3246 return;
3247 }
3248 let temp = tempfile::tempdir().unwrap();
3249 let scope = prepare_scope(
3250 true,
3251 &HostResourceLimits::default(),
3252 "ses_kill_fallback",
3253 temp.path(),
3254 )
3255 .expect("a delegated scope")
3256 .scope
3257 .expect("a required scope on Linux is never None");
3258 let enforcement = crate::sandbox::ShellEnforcement {
3259 cgroup_scope: Some(Arc::clone(&scope)),
3260 ..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
3261 };
3262 let mut command = std::process::Command::new("bash");
3263 command
3264 .args([
3265 "-c",
3266 "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
3267 ])
3268 .stdout(std::process::Stdio::null())
3269 .stderr(std::process::Stdio::null());
3270 crate::sandbox::attach_process_limits(&mut command, &enforcement);
3271 let mut child = command.spawn().expect("bash spawns into the scope");
3272
3273 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3274 while scope.member_pids().len() < 3 {
3275 assert!(
3276 std::time::Instant::now() < deadline,
3277 "the scope never held bash and both sleepers: {:?}",
3278 scope.member_pids()
3279 );
3280 std::thread::sleep(std::time::Duration::from_millis(20));
3281 }
3282 let members = scope.member_pids();
3283
3284 FORCE_KILL_FALLBACK.with(|flag| flag.set(true));
3285 let killed = scope.kill_all();
3286 FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
3287 let emptied = scope.wait_until_empty(std::time::Duration::from_secs(2));
3288 let status = child.wait().expect("bash is reaped");
3289 println!(
3290 "members before: {members:?}; fallback kill: {killed:?}; empty: {emptied}; \
3291 cgroup.procs after: {:?}; bash: {status}",
3292 scope.member_pids()
3293 );
3294 killed.expect("the fallback kill succeeds");
3295 assert!(emptied, "the scope still holds {:?}", scope.member_pids());
3296 assert_eq!(
3297 std::os::unix::process::ExitStatusExt::signal(&status),
3298 Some(libc::SIGKILL)
3299 );
3300 }
3301
3302 #[cfg(target_os = "linux")]
3305 fn scope_with_sleepers(
3306 session: &str,
3307 fallback: bool,
3308 ) -> (Arc<CgroupScope>, std::process::Child) {
3309 let temp = tempfile::tempdir().unwrap();
3310 FORCE_KILL_FALLBACK.with(|flag| flag.set(fallback));
3311 let scope = prepare_scope(true, &HostResourceLimits::default(), session, temp.path());
3312 FORCE_KILL_FALLBACK.with(|flag| flag.set(false));
3313 let scope = scope
3314 .expect("a delegated scope")
3315 .scope
3316 .expect("a required scope on Linux is never None");
3317 let enforcement = crate::sandbox::ShellEnforcement {
3318 cgroup_scope: Some(Arc::clone(&scope)),
3319 ..crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs()
3320 };
3321 let mut command = std::process::Command::new("bash");
3322 command
3323 .args([
3324 "-c",
3325 "setsid sleep 300 >/dev/null 2>&1 & sleep 300 >/dev/null 2>&1 & wait",
3326 ])
3327 .stdout(std::process::Stdio::null())
3328 .stderr(std::process::Stdio::null());
3329 crate::sandbox::attach_process_limits(&mut command, &enforcement);
3330 let child = command.spawn().expect("bash spawns into the scope");
3331 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3332 while scope.member_pids().len() < 3 {
3333 assert!(
3334 std::time::Instant::now() < deadline,
3335 "the scope never held bash and both sleepers: {:?}",
3336 scope.member_pids()
3337 );
3338 std::thread::sleep(std::time::Duration::from_millis(20));
3339 }
3340 (scope, child)
3341 }
3342
3343 #[cfg(target_os = "linux")]
3344 fn watcher_of(scope: &CgroupScope) -> &ScopeWatcher {
3345 scope
3346 .watcher
3347 .as_ref()
3348 .expect("a scope on Linux has a watcher")
3349 }
3350
3351 #[cfg(target_os = "linux")]
3354 fn assert_a_cut_lifeline_empties_the_scope(test_name: &str, fallback: bool) {
3355 if skip_without_host_support(test_name) {
3356 return;
3357 }
3358 let (scope, mut child) = scope_with_sleepers(test_name, fallback);
3359 let members = scope.member_pids();
3360 watcher_of(&scope).cut_lifeline();
3361 assert!(
3362 scope.wait_until_empty(std::time::Duration::from_secs(5)),
3363 "the watcher left {:?} of {members:?} in the scope",
3364 scope.member_pids()
3365 );
3366 for pid in members {
3367 assert!(
3368 !Path::new(&format!("/proc/{pid}")).exists()
3369 || std::fs::read_to_string(format!("/proc/{pid}/stat"))
3370 .is_ok_and(|stat| stat.contains(") Z ")),
3371 "{pid} is still running"
3372 );
3373 }
3374 let status = child.wait().expect("bash is reaped");
3375 assert_eq!(
3376 std::os::unix::process::ExitStatusExt::signal(&status),
3377 Some(libc::SIGKILL)
3378 );
3379 }
3380
3381 #[cfg(target_os = "linux")]
3382 #[test]
3383 fn scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes() {
3384 assert_a_cut_lifeline_empties_the_scope(
3385 "scope_watcher_kills_the_scope_through_cgroup_kill_when_the_lifeline_closes",
3386 false,
3387 );
3388 }
3389
3390 #[cfg(target_os = "linux")]
3391 #[test]
3392 fn scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes() {
3393 assert_a_cut_lifeline_empties_the_scope(
3394 "scope_watcher_kills_the_scope_by_freezing_when_the_lifeline_closes",
3395 true,
3396 );
3397 }
3398
3399 #[cfg(target_os = "linux")]
3403 #[test]
3404 #[allow(unsafe_code)]
3405 fn scope_watcher_holds_only_its_own_descriptors_outside_the_scope() {
3406 if skip_without_host_support(
3407 "scope_watcher_holds_only_its_own_descriptors_outside_the_scope",
3408 ) {
3409 return;
3410 }
3411 let own = tempfile::tempfile().unwrap();
3413 let own_target = std::fs::read_link(format!(
3414 "/proc/self/fd/{}",
3415 std::os::fd::AsRawFd::as_raw_fd(&own)
3416 ))
3417 .unwrap();
3418 let (scope, mut child) = scope_with_sleepers("ses_watcher_fds", false);
3419 let pid = watcher_of(&scope).pid;
3420
3421 let fd_dir = format!("/proc/{pid}/fd");
3422 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3423 let targets = loop {
3424 let targets: Vec<(String, PathBuf)> = std::fs::read_dir(&fd_dir)
3425 .unwrap()
3426 .filter_map(Result::ok)
3427 .filter_map(|entry| {
3428 let target = std::fs::read_link(entry.path()).ok()?;
3429 Some((entry.file_name().to_string_lossy().into_owned(), target))
3430 })
3431 .collect();
3432 let settled = targets.iter().all(|(_, target)| {
3433 target == Path::new("/dev/null")
3434 || target.to_string_lossy().starts_with("pipe:")
3435 || target.starts_with(scope.path())
3436 });
3437 if settled || std::time::Instant::now() >= deadline {
3438 break targets;
3439 }
3440 std::thread::sleep(std::time::Duration::from_millis(20));
3441 };
3442 let mut numbers: Vec<u32> = targets
3443 .iter()
3444 .map(|(name, _)| name.parse().unwrap())
3445 .collect();
3446 numbers.sort_unstable();
3447 assert!(numbers.starts_with(&[0, 1, 2]), "{targets:?}");
3448 for (name, target) in &targets {
3449 assert_ne!(target, &own_target, "fd {name}");
3450 if ["0", "1", "2"].contains(&name.as_str()) {
3451 assert_eq!(target, Path::new("/dev/null"), "fd {name}");
3452 } else {
3453 assert!(
3454 target.to_string_lossy().starts_with("pipe:")
3455 || target.starts_with(scope.path()),
3456 "fd {name} -> {}",
3457 target.display()
3458 );
3459 }
3460 }
3461 let pipes = targets
3462 .iter()
3463 .filter(|(_, target)| target.to_string_lossy().starts_with("pipe:"))
3464 .count();
3465 assert_eq!(pipes, 1, "the lifeline is its only pipe: {targets:?}");
3466
3467 assert!(!scope.member_pids().contains(&pid));
3468 assert_eq!(
3470 unsafe { libc::getpgid(pid) },
3471 pid,
3472 "a process group of its own"
3473 );
3474
3475 scope.kill_all().unwrap();
3476 child.wait().unwrap();
3477 }
3478
3479 #[cfg(target_os = "linux")]
3481 #[test]
3482 #[allow(unsafe_code)]
3483 fn scope_watcher_is_reaped_when_the_scope_drops() {
3484 if skip_without_host_support("scope_watcher_is_reaped_when_the_scope_drops") {
3485 return;
3486 }
3487 let (scope, mut child) = scope_with_sleepers("ses_watcher_reaped", false);
3488 let pid = watcher_of(&scope).pid;
3489 let path = scope.path().to_path_buf();
3490 drop(Arc::try_unwrap(scope).expect("the test holds the only reference"));
3491 child.wait().unwrap();
3492 let mut status = 0;
3493 let waited = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
3495 assert_eq!(waited, -1);
3496 assert_eq!(
3497 std::io::Error::last_os_error().raw_os_error(),
3498 Some(libc::ECHILD)
3499 );
3500 assert!(!path.exists(), "the scope directory is removed");
3501 }
3502
3503 #[test]
3506 fn the_scope_limits_bound_swap_at_zero() {
3507 let declared = HostResourceLimits {
3508 cgroup_memory_bytes: 268_435_456,
3509 cgroup_cpu_percent: Some(50),
3510 ..HostResourceLimits::default()
3511 };
3512 for limits in [HostResourceLimits::default(), declared] {
3513 let writes: Vec<(&str, String)> = scope_limits(&limits)
3514 .into_iter()
3515 .map(|limit| (limit.file, limit.value))
3516 .collect();
3517 assert!(
3518 writes.contains(&("memory.swap.max", "0".to_string())),
3519 "writes were {writes:?}"
3520 );
3521 assert!(writes.contains(&("memory.max", limits.cgroup_memory_bytes.to_string())));
3522 assert_eq!(
3523 &writes.iter().map(|(file, _)| *file).collect::<Vec<_>>()[..3],
3524 ["memory.max", "memory.swap.max", "pids.max"]
3525 );
3526 }
3527 let absent_ok: Vec<&str> = scope_limits(&HostResourceLimits::default())
3528 .into_iter()
3529 .filter(|limit| limit.absent_ok)
3530 .map(|limit| limit.file)
3531 .collect();
3532 assert_eq!(absent_ok, ["memory.swap.max"]);
3533 }
3534
3535 #[test]
3538 fn the_scope_limits_write_cpu_max_only_for_a_declared_cap() {
3539 let files: Vec<&str> = scope_limits(&HostResourceLimits::resolve(None))
3540 .into_iter()
3541 .map(|limit| limit.file)
3542 .collect();
3543 assert_eq!(files, ["memory.max", "memory.swap.max", "pids.max"]);
3544
3545 let capped = HostResourceLimits {
3546 cgroup_cpu_percent: Some(50),
3547 ..HostResourceLimits::default()
3548 };
3549 let writes = scope_limits(&capped);
3550 assert_eq!(writes.len(), 4);
3551 assert_eq!(writes[3].file, "cpu.max");
3552 assert_eq!(writes[3].value, "50000 100000");
3553 assert!(!writes[3].absent_ok);
3554 }
3555
3556 #[test]
3557 fn active_swap_areas_are_the_lines_below_the_header() {
3558 let header = "Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n";
3559 assert_eq!(count_swap_areas(header), 0);
3560 assert_eq!(count_swap_areas(""), 0);
3561 assert_eq!(
3562 count_swap_areas(&format!(
3563 "{header}/swapfile file\t\t33554428\t\t0\t\t-2\n"
3564 )),
3565 1
3566 );
3567 }
3568
3569 #[cfg(target_os = "linux")]
3572 #[test]
3573 fn transient_scope_unit_names_are_murmur_prefixed_and_unique() {
3574 let first = transient_scope_unit_name();
3575 let second = transient_scope_unit_name();
3576 assert!(first.starts_with(&format!("murmur-{}-", std::process::id())));
3577 assert!(first.ends_with(".scope"));
3578 assert_ne!(first, second);
3579 }
3580
3581 #[test]
3582 fn unified_cgroup_path_is_read_from_the_zero_hierarchy_line() {
3583 let v2_only = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope\n";
3584 assert_eq!(
3585 parse_unified_cgroup_path(v2_only).as_deref(),
3586 Some("/user.slice/user-1000.slice/user@1000.service/app.slice/mur.scope")
3587 );
3588
3589 let hybrid = "12:pids:/user.slice\n11:memory:/user.slice\n0::/user.slice/session.scope\n";
3590 assert_eq!(
3591 parse_unified_cgroup_path(hybrid).as_deref(),
3592 Some("/user.slice/session.scope")
3593 );
3594
3595 let v1_only = "12:pids:/user.slice\n11:memory:/user.slice\n";
3596 assert_eq!(parse_unified_cgroup_path(v1_only), None);
3597 }
3598
3599 #[test]
3600 fn cgroup2_mount_falls_back_to_the_conventional_path() {
3601 let mounts = "cgroup2 /custom/cgroup cgroup2 rw,nsdelegate 0 0\n";
3602 assert_eq!(parse_cgroup2_mount(mounts), "/custom/cgroup");
3603 assert_eq!(
3604 parse_cgroup2_mount("proc /proc proc rw 0 0\n"),
3605 "/sys/fs/cgroup"
3606 );
3607 }
3608
3609 #[test]
3610 fn missing_controllers_reports_only_what_is_absent() {
3611 assert_eq!(
3612 missing_controllers("cpu memory", REQUIRED_CONTROLLERS),
3613 vec!["pids".to_string()]
3614 );
3615 assert!(missing_controllers("cpu io memory pids", REQUIRED_CONTROLLERS).is_empty());
3616 assert_eq!(
3617 missing_controllers("", REQUIRED_CONTROLLERS),
3618 vec!["memory".to_string(), "pids".to_string(), "cpu".to_string()]
3619 );
3620 }
3621
3622 #[test]
3623 fn event_counters_parse_the_named_key_only() {
3624 let memory_events = "low 0\nhigh 0\nmax 12\noom 3\noom_kill 2\n";
3625 assert_eq!(parse_event_counter(memory_events, "oom_kill"), 2);
3626 assert_eq!(parse_event_counter(memory_events, "max"), 12);
3627 assert_eq!(parse_event_counter("max 7\n", "max"), 7);
3628 assert_eq!(parse_event_counter("", "oom_kill"), 0);
3629 assert_eq!(parse_event_counter("oom_kill\n", "oom_kill"), 0);
3630 }
3631
3632 #[test]
3635 fn attribution_uses_the_delta_across_one_call() {
3636 let before = CgroupEventCounters {
3637 oom_kill: 1,
3638 pids_max: 4,
3639 };
3640 assert_eq!(before.attribution_since(before), None);
3641 assert_eq!(
3642 CgroupEventCounters {
3643 oom_kill: 2,
3644 pids_max: 4
3645 }
3646 .attribution_since(before),
3647 Some("cgroup_memory_bytes")
3648 );
3649 assert_eq!(
3650 CgroupEventCounters {
3651 oom_kill: 1,
3652 pids_max: 5
3653 }
3654 .attribution_since(before),
3655 Some("cgroup_pids_max")
3656 );
3657 }
3658
3659 fn scope_without_kernel() -> CgroupScope {
3662 CgroupScope {
3663 path: PathBuf::new(),
3664 procs_fd: None,
3665 occupancy: Arc::default(),
3666 #[cfg(target_os = "linux")]
3667 watcher: None,
3668 }
3669 }
3670
3671 #[test]
3672 fn occupancy_alone_is_exclusive() {
3673 let scope = scope_without_kernel();
3674 let call = scope.enter();
3675 assert!(call.exclusive());
3676 }
3677
3678 #[test]
3679 fn occupancy_entering_beside_another_is_not_exclusive() {
3680 let scope = scope_without_kernel();
3681 let first = scope.enter();
3682 let second = scope.enter();
3683 assert!(!second.exclusive(), "someone was already inside");
3684 drop(first);
3685 assert!(
3686 !second.exclusive(),
3687 "the sibling leaving does not clean the window it shared"
3688 );
3689 }
3690
3691 #[test]
3692 fn occupancy_someone_entering_during_the_window_makes_it_shared() {
3693 let scope = scope_without_kernel();
3694 let first = scope.enter();
3695 assert!(first.exclusive());
3696 drop(scope.enter());
3697 assert!(
3698 !first.exclusive(),
3699 "a call that entered and left inside this window could have moved the counters"
3700 );
3701 }
3702
3703 #[test]
3704 fn occupancy_drop_releases_the_scope() {
3705 let scope = scope_without_kernel();
3706 drop(scope.enter());
3707 let next = scope.enter();
3708 assert!(
3709 next.exclusive(),
3710 "an earlier call that has left does not share this window"
3711 );
3712 }
3713
3714 #[test]
3717 fn occupancy_guard_outlives_its_borrow_on_another_thread() {
3718 let scope = scope_without_kernel();
3719 let demoted = scope.enter();
3720 let held = std::thread::spawn(move || demoted);
3721 let demoted = held.join().unwrap();
3722 let foreground = scope.enter();
3723 assert!(!foreground.exclusive());
3724 assert!(!demoted.exclusive());
3725 }
3726
3727 #[test]
3728 fn scope_dir_name_is_sanitized_and_never_empty() {
3729 assert!(scope_dir_name("ses_abc123").starts_with("murmur-ses_abc123-"));
3730 assert!(scope_dir_name("../escape").starts_with("murmur-.._escape-"));
3731 assert!(!scope_dir_name("../escape").contains('/'));
3732 assert!(scope_dir_name("").starts_with("murmur-"));
3733 }
3734
3735 #[test]
3739 fn delegated_base_ascends_out_of_inherited_supervisor_leaves() {
3740 let base = PathBuf::from("/sys/fs/cgroup/user.slice/app.slice/mur.scope");
3741
3742 assert_eq!(ascend_out_of_supervisor_leaves(base.clone()), base);
3743 assert_eq!(
3744 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF)),
3745 base
3746 );
3747 assert_eq!(
3748 ascend_out_of_supervisor_leaves(base.join(SUPERVISOR_LEAF).join(SUPERVISOR_LEAF)),
3749 base,
3750 "a base already nested by an earlier build must still resolve to the real root"
3751 );
3752 }
3753
3754 #[cfg(target_os = "linux")]
3759 #[test]
3760 fn background_resolution_agrees_with_a_direct_one() {
3761 resolve_delegated_base_in_background();
3762 let first = delegated_base();
3763 let second = delegated_base();
3764 assert_eq!(first, second);
3765 }
3766
3767 #[test]
3771 fn scope_dir_name_is_unique_per_call_for_one_id() {
3772 let first = scope_dir_name("p");
3773 let second = scope_dir_name("p");
3774 assert_ne!(
3775 first, second,
3776 "same plan id must not reuse a scope directory"
3777 );
3778 }
3779
3780 #[test]
3781 fn pid_is_formatted_without_allocating() {
3782 let mut buf = [0u8; 24];
3783 let len = format_pid(4321, &mut buf);
3784 assert_eq!(&buf[..len], b"4321\n");
3785
3786 let len = format_pid(7, &mut buf);
3787 assert_eq!(&buf[..len], b"7\n");
3788 }
3789
3790 #[test]
3791 fn device_numbers_use_the_wide_glibc_encoding() {
3792 assert_eq!(device_major_minor(0x800), (8, 0));
3794 assert_eq!(device_major_minor((259 << 8) | 3), (259, 3));
3796 }
3797}