Skip to main content

nucleus/resources/
cgroup.rs

1use crate::error::{NucleusError, Result, StateTransition};
2use crate::resources::{CgroupState, ResourceLimits};
3use nix::sys::signal::{kill, Signal};
4use nix::unistd::Pid;
5use std::ffi::{CString, OsString};
6use std::fs;
7use std::io::Write;
8use std::mem::MaybeUninit;
9use std::os::unix::ffi::OsStrExt;
10use std::os::unix::fs::OpenOptionsExt;
11use std::os::unix::io::{AsRawFd, RawFd};
12use std::path::{Component, Path, PathBuf};
13use std::thread;
14use std::time::Duration;
15use tracing::{debug, info, warn};
16
17const CGROUP_V2_ROOT: &str = "/sys/fs/cgroup";
18const CGROUP2_SUPER_MAGIC: libc::c_long = 0x6367_7270;
19const NUCLEUS_CGROUP_ROOT_ENV: &str = "NUCLEUS_CGROUP_ROOT";
20const CGROUP_CLEANUP_RETRIES: usize = 50;
21const CGROUP_CLEANUP_SLEEP: Duration = Duration::from_millis(20);
22const CGROUP_FREEZE_RETRIES: usize = 100;
23const CGROUP_FREEZE_SLEEP: Duration = Duration::from_millis(10);
24
25/// Cgroup v2 manager
26///
27/// Implements the cgroup lifecycle state machine from
28/// Nucleus_Resources_CgroupLifecycle.tla
29pub struct Cgroup {
30    path: PathBuf,
31    state: CgroupState,
32}
33
34/// RAII guard for a frozen cgroup v2 subtree.
35pub struct CgroupFreezeGuard {
36    path: PathBuf,
37    active: bool,
38}
39
40impl Cgroup {
41    /// Create a new cgroup with the given name
42    ///
43    /// State transition: Nonexistent -> Created
44    pub fn create(name: &str) -> Result<Self> {
45        let root = Self::root_path()?;
46        Self::create_in_root(name, &root)
47    }
48
49    fn create_in_root(name: &str, root: &Path) -> Result<Self> {
50        Self::validate_cgroup_name(name)?;
51        Self::validate_root_path(root)?;
52
53        let state = CgroupState::Nonexistent.transition(CgroupState::Created)?;
54        let path = root.join(name);
55
56        info!("Creating cgroup at {:?}", path);
57
58        // Create cgroup directory
59        fs::create_dir_all(&path).map_err(|e| {
60            NucleusError::CgroupError(format!("Failed to create cgroup directory: {}", e))
61        })?;
62
63        Self::validate_cgroup_directory(&path)?;
64
65        Ok(Self { path, state })
66    }
67
68    fn root_path() -> Result<PathBuf> {
69        let path = Self::root_path_from_override(std::env::var_os(NUCLEUS_CGROUP_ROOT_ENV))?;
70        Self::validate_root_path(&path)?;
71        Ok(path)
72    }
73
74    fn root_path_from_override(raw: Option<OsString>) -> Result<PathBuf> {
75        match raw {
76            Some(raw) if !raw.as_os_str().is_empty() => {
77                let path = PathBuf::from(raw);
78                if !path.is_absolute() {
79                    return Err(NucleusError::CgroupError(format!(
80                        "{} must be an absolute path",
81                        NUCLEUS_CGROUP_ROOT_ENV
82                    )));
83                }
84                Ok(path)
85            }
86            _ => Ok(PathBuf::from(CGROUP_V2_ROOT)),
87        }
88    }
89
90    fn validate_cgroup_name(name: &str) -> Result<()> {
91        if name.is_empty() || name.as_bytes().contains(&0) {
92            return Err(NucleusError::CgroupError(
93                "cgroup name must be a non-empty path component".to_string(),
94            ));
95        }
96
97        let mut components = Path::new(name).components();
98        match (components.next(), components.next()) {
99            (Some(Component::Normal(_)), None) => Ok(()),
100            _ => Err(NucleusError::CgroupError(
101                "cgroup name must be a single relative path component".to_string(),
102            )),
103        }
104    }
105
106    fn validate_root_path(path: &Path) -> Result<()> {
107        if !path.is_absolute() {
108            return Err(NucleusError::CgroupError(format!(
109                "{} must be an absolute path",
110                NUCLEUS_CGROUP_ROOT_ENV
111            )));
112        }
113
114        Self::validate_directory_not_symlink(path, "cgroup root")?;
115        let canonical = fs::canonicalize(path).map_err(|e| {
116            NucleusError::CgroupError(format!(
117                "Failed to canonicalize cgroup root {:?}: {}",
118                path, e
119            ))
120        })?;
121        let canonical_cgroup_root = fs::canonicalize(CGROUP_V2_ROOT).map_err(|e| {
122            NucleusError::CgroupError(format!(
123                "Failed to canonicalize {} while validating cgroup root {:?}: {}",
124                CGROUP_V2_ROOT, path, e
125            ))
126        })?;
127
128        if !canonical.starts_with(&canonical_cgroup_root) {
129            return Err(NucleusError::CgroupError(format!(
130                "cgroup root {:?} must be inside {}",
131                path, CGROUP_V2_ROOT
132            )));
133        }
134
135        Self::ensure_path_on_cgroup2_fs(&canonical)?;
136        Self::require_cgroup_control_file(&canonical.join("cgroup.controllers"))?;
137        Self::require_cgroup_control_file(&canonical.join("cgroup.subtree_control"))?;
138        Self::require_cgroup_control_file(&canonical.join("cgroup.procs"))?;
139        Ok(())
140    }
141
142    fn validate_cgroup_directory(path: &Path) -> Result<()> {
143        Self::validate_directory_not_symlink(path, "cgroup directory")?;
144        Self::ensure_path_on_cgroup2_fs(path)?;
145        Self::require_cgroup_control_file(&path.join("cgroup.procs"))?;
146        Ok(())
147    }
148
149    fn validate_directory_not_symlink(path: &Path, description: &str) -> Result<()> {
150        let metadata = fs::symlink_metadata(path).map_err(|e| {
151            NucleusError::CgroupError(format!(
152                "Failed to inspect {} {:?}: {}",
153                description, path, e
154            ))
155        })?;
156        let file_type = metadata.file_type();
157        if file_type.is_symlink() {
158            return Err(NucleusError::CgroupError(format!(
159                "{} {:?} must not be a symlink",
160                description, path
161            )));
162        }
163        if !file_type.is_dir() {
164            return Err(NucleusError::CgroupError(format!(
165                "{} {:?} must be a directory",
166                description, path
167            )));
168        }
169        Ok(())
170    }
171
172    fn require_cgroup_control_file(path: &Path) -> Result<()> {
173        let metadata = fs::symlink_metadata(path).map_err(|e| {
174            NucleusError::CgroupError(format!(
175                "Required cgroup control file {:?} is missing or inaccessible: {}",
176                path, e
177            ))
178        })?;
179        let file_type = metadata.file_type();
180        if file_type.is_symlink() {
181            return Err(NucleusError::CgroupError(format!(
182                "cgroup control file {:?} must not be a symlink",
183                path
184            )));
185        }
186        if !file_type.is_file() {
187            return Err(NucleusError::CgroupError(format!(
188                "{:?} is not a cgroup control file",
189                path
190            )));
191        }
192        Self::ensure_path_on_cgroup2_fs(path)
193    }
194
195    fn ensure_path_on_cgroup2_fs(path: &Path) -> Result<()> {
196        let statfs = Self::statfs_path(path)?;
197        Self::ensure_cgroup2_magic(statfs.f_type, path)
198    }
199
200    fn ensure_fd_on_cgroup2_fs(fd: RawFd, path: &Path) -> Result<()> {
201        let mut statfs = MaybeUninit::<libc::statfs>::uninit();
202        // SAFETY: `statfs` points to valid uninitialized storage and `fd` is
203        // borrowed from an open file for the duration of this call.
204        let rc = unsafe { libc::fstatfs(fd, statfs.as_mut_ptr()) };
205        if rc != 0 {
206            return Err(NucleusError::CgroupError(format!(
207                "Failed to statfs opened cgroup control file {:?}: {}",
208                path,
209                std::io::Error::last_os_error()
210            )));
211        }
212        // SAFETY: `fstatfs` returned success, so the kernel initialized the
213        // struct.
214        let statfs = unsafe { statfs.assume_init() };
215        Self::ensure_cgroup2_magic(statfs.f_type, path)
216    }
217
218    fn statfs_path(path: &Path) -> Result<libc::statfs> {
219        let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
220            NucleusError::CgroupError(format!("cgroup path {:?} contains an interior NUL", path))
221        })?;
222        let mut statfs = MaybeUninit::<libc::statfs>::uninit();
223        // SAFETY: `c_path` is a NUL-terminated path and `statfs` points to
224        // valid uninitialized storage for the kernel to fill.
225        let rc = unsafe { libc::statfs(c_path.as_ptr(), statfs.as_mut_ptr()) };
226        if rc != 0 {
227            return Err(NucleusError::CgroupError(format!(
228                "Failed to statfs cgroup path {:?}: {}",
229                path,
230                std::io::Error::last_os_error()
231            )));
232        }
233        // SAFETY: `statfs` returned success, so the kernel initialized the
234        // struct.
235        Ok(unsafe { statfs.assume_init() })
236    }
237
238    fn ensure_cgroup2_magic(fs_type: libc::c_long, path: &Path) -> Result<()> {
239        if fs_type != CGROUP2_SUPER_MAGIC {
240            return Err(NucleusError::CgroupError(format!(
241                "{:?} is not on a cgroup v2 filesystem",
242                path
243            )));
244        }
245        Ok(())
246    }
247
248    /// Set resource limits
249    ///
250    /// State transition: Created -> Configured
251    pub fn set_limits(&mut self, limits: &ResourceLimits) -> Result<()> {
252        self.state = self.state.transition(CgroupState::Configured)?;
253
254        info!("Configuring cgroup limits: {:?}", limits);
255
256        // Set memory limit
257        if let Some(memory_bytes) = limits.memory_bytes {
258            self.write_value("memory.max", &memory_bytes.to_string())?;
259            debug!("Set memory.max = {}", memory_bytes);
260        }
261
262        // Set memory soft limit (high watermark)
263        if let Some(memory_high) = limits.memory_high {
264            self.write_value("memory.high", &memory_high.to_string())?;
265            debug!("Set memory.high = {}", memory_high);
266        }
267
268        // Set swap limit
269        if let Some(swap_max) = limits.memory_swap_max {
270            self.write_value("memory.swap.max", &swap_max.to_string())?;
271            debug!("Set memory.swap.max = {}", swap_max);
272        }
273        if limits.memory_bytes.is_some()
274            || limits.memory_high.is_some()
275            || limits.memory_swap_max.is_some()
276        {
277            self.write_value("memory.oom.group", "1")?;
278            debug!("Set memory.oom.group = 1");
279        }
280
281        // Set CPU limit
282        if let Some(cpu_quota_us) = limits.cpu_quota_us {
283            let cpu_max = format!("{} {}", cpu_quota_us, limits.cpu_period_us);
284            self.write_value("cpu.max", &cpu_max)?;
285            debug!("Set cpu.max = {}", cpu_max);
286        }
287
288        // Set CPU weight
289        if let Some(cpu_weight) = limits.cpu_weight {
290            self.write_value("cpu.weight", &cpu_weight.to_string())?;
291            debug!("Set cpu.weight = {}", cpu_weight);
292        }
293
294        // Set PID limit
295        if let Some(pids_max) = limits.pids_max {
296            self.write_value("pids.max", &pids_max.to_string())?;
297            debug!("Set pids.max = {}", pids_max);
298        }
299
300        // Set I/O limits
301        for io_limit in &limits.io_limits {
302            let line = io_limit.to_io_max_line();
303            self.write_value("io.max", &line)?;
304            debug!("Set io.max: {}", line);
305        }
306
307        info!("Successfully configured cgroup limits");
308
309        Ok(())
310    }
311
312    /// Attach a process to this cgroup
313    ///
314    /// State transition: Configured -> Attached
315    pub fn attach_process(&mut self, pid: u32) -> Result<()> {
316        self.state = self.state.transition(CgroupState::Attached)?;
317
318        info!("Attaching process {} to cgroup", pid);
319
320        self.write_value("cgroup.procs", &pid.to_string())?;
321
322        info!("Successfully attached process to cgroup");
323
324        Ok(())
325    }
326
327    /// Write a value to a cgroup file
328    fn write_value(&self, file: &str, value: &str) -> Result<()> {
329        Self::write_value_at(&self.path, file, value)
330    }
331
332    fn write_value_at(path: &Path, file: &str, value: &str) -> Result<()> {
333        Self::validate_cgroup_name(file)?;
334        let file_path = path.join(file);
335        let mut control_file = fs::OpenOptions::new()
336            .write(true)
337            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
338            .open(&file_path)
339            .map_err(|e| {
340                NucleusError::CgroupError(format!(
341                    "Failed to open cgroup control file {:?}: {}",
342                    file_path, e
343                ))
344            })?;
345        Self::ensure_fd_on_cgroup2_fs(control_file.as_raw_fd(), &file_path)?;
346        control_file.write_all(value.as_bytes()).map_err(|e| {
347            NucleusError::CgroupError(format!(
348                "Failed to write {} to {:?}: {}",
349                value, file_path, e
350            ))
351        })?;
352        Ok(())
353    }
354
355    /// Read a value from a cgroup file
356    fn read_value(&self, file: &str) -> Result<String> {
357        Self::read_value_at(&self.path, file)
358    }
359
360    fn read_value_at(path: &Path, file: &str) -> Result<String> {
361        Self::validate_cgroup_name(file)?;
362        let file_path = path.join(file);
363        fs::read_to_string(&file_path).map_err(|e| {
364            NucleusError::CgroupError(format!("Failed to read {:?}: {}", file_path, e))
365        })
366    }
367
368    fn set_frozen(&self, frozen: bool) -> Result<bool> {
369        let freeze_path = self.path.join("cgroup.freeze");
370        if !freeze_path.exists() {
371            return Ok(false);
372        }
373        self.write_value("cgroup.freeze", if frozen { "1" } else { "0" })?;
374        debug!("Set cgroup.freeze = {}", if frozen { 1 } else { 0 });
375        Ok(true)
376    }
377
378    fn parse_cgroup_events_bool(events: &str, key: &str) -> Result<bool> {
379        for line in events.lines() {
380            if let Some(value) = line
381                .strip_prefix(key)
382                .and_then(|rest| rest.strip_prefix(' '))
383            {
384                return match value.trim() {
385                    "0" => Ok(false),
386                    "1" => Ok(true),
387                    other => Err(NucleusError::CgroupError(format!(
388                        "Unexpected {} value in cgroup.events: {}",
389                        key, other
390                    ))),
391                };
392            }
393        }
394        Err(NucleusError::CgroupError(format!(
395            "Missing {} entry in cgroup.events",
396            key
397        )))
398    }
399
400    fn parse_cgroup_events_populated(events: &str) -> Result<bool> {
401        Self::parse_cgroup_events_bool(events, "populated")
402    }
403
404    fn read_cgroup_events_frozen(path: &Path) -> Result<bool> {
405        let events = Self::read_value_at(path, "cgroup.events")?;
406        Self::parse_cgroup_events_bool(&events, "frozen")
407    }
408
409    fn wait_for_frozen_state(path: &Path, frozen: bool) -> Result<()> {
410        let events_path = path.join("cgroup.events");
411        if !events_path.exists() {
412            return Ok(());
413        }
414
415        for attempt in 0..CGROUP_FREEZE_RETRIES {
416            if Self::read_cgroup_events_frozen(path)? == frozen {
417                return Ok(());
418            }
419            if attempt + 1 < CGROUP_FREEZE_RETRIES {
420                thread::sleep(CGROUP_FREEZE_SLEEP);
421            }
422        }
423
424        Err(NucleusError::CgroupError(format!(
425            "Timed out waiting for cgroup {:?} to become {}",
426            path,
427            if frozen { "frozen" } else { "thawed" }
428        )))
429    }
430
431    /// Freeze an existing cgroup v2 subtree until the returned guard is dropped.
432    pub fn freeze_existing(path: &Path) -> Result<CgroupFreezeGuard> {
433        Self::validate_cgroup_directory(path)?;
434        let freeze_path = path.join("cgroup.freeze");
435        if !freeze_path.exists() {
436            return Err(NucleusError::CgroupError(format!(
437                "Cgroup {:?} does not support cgroup.freeze",
438                path
439            )));
440        }
441
442        Self::write_value_at(path, "cgroup.freeze", "1")?;
443        Self::wait_for_frozen_state(path, true)?;
444        debug!("Frozen cgroup {:?}", path);
445        Ok(CgroupFreezeGuard {
446            path: path.to_path_buf(),
447            active: true,
448        })
449    }
450
451    fn read_pids(&self) -> Result<Vec<Pid>> {
452        let file_path = self.path.join("cgroup.procs");
453        if !file_path.exists() {
454            return Ok(Vec::new());
455        }
456        let content = fs::read_to_string(&file_path).map_err(|e| {
457            NucleusError::CgroupError(format!("Failed to read {:?}: {}", file_path, e))
458        })?;
459        content
460            .lines()
461            .filter(|line| !line.trim().is_empty())
462            .map(|line| {
463                line.trim().parse::<i32>().map(Pid::from_raw).map_err(|e| {
464                    NucleusError::CgroupError(format!(
465                        "Failed to parse pid '{}' from {:?}: {}",
466                        line.trim(),
467                        file_path,
468                        e
469                    ))
470                })
471            })
472            .collect()
473    }
474
475    fn is_populated(&self) -> Result<bool> {
476        let events_path = self.path.join("cgroup.events");
477        if events_path.exists() {
478            let events = fs::read_to_string(&events_path).map_err(|e| {
479                NucleusError::CgroupError(format!("Failed to read {:?}: {}", events_path, e))
480            })?;
481            return Self::parse_cgroup_events_populated(&events);
482        }
483        Ok(!self.read_pids()?.is_empty())
484    }
485
486    fn kill_visible_processes(&self) -> Result<()> {
487        for pid in self.read_pids()? {
488            match kill(pid, Signal::SIGKILL) {
489                Ok(()) => {}
490                Err(nix::errno::Errno::ESRCH) => {}
491                Err(e) => {
492                    return Err(NucleusError::CgroupError(format!(
493                        "Failed to SIGKILL pid {} in {:?}: {}",
494                        pid, self.path, e
495                    )))
496                }
497            }
498        }
499        Ok(())
500    }
501
502    fn kill_all_processes(&self) -> Result<()> {
503        let kill_path = self.path.join("cgroup.kill");
504        if kill_path.exists() {
505            self.write_value("cgroup.kill", "1")?;
506            debug!("Triggered cgroup.kill for {:?}", self.path);
507        }
508        self.kill_visible_processes()
509    }
510
511    fn wait_until_empty(&self) -> Result<()> {
512        for attempt in 0..CGROUP_CLEANUP_RETRIES {
513            if !self.is_populated()? {
514                return Ok(());
515            }
516            if attempt + 1 < CGROUP_CLEANUP_RETRIES {
517                self.kill_visible_processes()?;
518                thread::sleep(CGROUP_CLEANUP_SLEEP);
519            }
520        }
521
522        let remaining = self
523            .read_pids()?
524            .into_iter()
525            .map(|pid| pid.to_string())
526            .collect::<Vec<_>>();
527        Err(NucleusError::CgroupError(format!(
528            "Timed out waiting for cgroup {:?} to drain (remaining pids: {})",
529            self.path,
530            if remaining.is_empty() {
531                "<unknown>".to_string()
532            } else {
533                remaining.join(", ")
534            }
535        )))
536    }
537
538    /// Get current memory usage
539    pub fn memory_current(&self) -> Result<u64> {
540        let value = self.read_value("memory.current")?;
541        value.trim().parse().map_err(|e| {
542            NucleusError::CgroupError(format!("Failed to parse memory.current: {}", e))
543        })
544    }
545
546    /// Install a cgroup v2 device allowlist (`BPF_PROG_TYPE_CGROUP_DEVICE`).
547    ///
548    /// When GPU passthrough is enabled, this restricts device access to the
549    /// base `/dev` nodes plus the bound GPU devices, so a compromised workload
550    /// cannot reach other host devices even if it creates a device node.
551    ///
552    /// `best_effort` matches `--allow-degraded-security`: when `true`, a kernel
553    /// or capability limitation degrades to a warning rather than failing the
554    /// launch, because the filesystem layer (only bound device nodes exist in
555    /// `/dev`) remains the primary gate.
556    pub fn install_gpu_device_allowlist(
557        &self,
558        gpu_specs: &[crate::filesystem::DeviceNodeSpec],
559        include_tty: bool,
560        best_effort: bool,
561    ) -> Result<bool> {
562        use crate::resources::{base_device_specs, install_device_allowlist, DeviceAllowSpec};
563        let mut specs: Vec<DeviceAllowSpec> = base_device_specs(include_tty);
564        for dev in gpu_specs {
565            specs.push(DeviceAllowSpec {
566                is_block: dev.is_block,
567                major: dev.major,
568                minor: dev.minor,
569            });
570        }
571        install_device_allowlist(&self.path, &specs, best_effort)
572    }
573
574    /// Get cgroup path
575    pub fn path(&self) -> &Path {
576        &self.path
577    }
578
579    /// Get the current state of this cgroup
580    pub fn state(&self) -> CgroupState {
581        self.state
582    }
583
584    /// Clean up the cgroup
585    ///
586    /// State transition: * -> Removed (only on success)
587    pub fn cleanup(mut self) -> Result<()> {
588        info!("Cleaning up cgroup {:?}", self.path);
589
590        if self.path.exists() {
591            let froze = self.set_frozen(true)?;
592            let cleanup_result: Result<()> = (|| {
593                self.kill_all_processes()?;
594                self.wait_until_empty()?;
595                fs::remove_dir(&self.path).map_err(|e| {
596                    // BUG-06: Do NOT set state to Removed on failure – Drop should
597                    // still attempt cleanup when the Cgroup is dropped.
598                    NucleusError::CgroupError(format!("Failed to remove cgroup: {}", e))
599                })?;
600                Ok(())
601            })();
602            if cleanup_result.is_err() && froze {
603                if let Err(e) = self.set_frozen(false) {
604                    warn!(
605                        "Failed to unfreeze cgroup {:?} after cleanup error: {}",
606                        self.path, e
607                    );
608                }
609            }
610            cleanup_result?;
611        }
612
613        // Only mark as terminal after successful removal
614        self.state = CgroupState::Removed;
615        info!("Successfully cleaned up cgroup");
616
617        Ok(())
618    }
619}
620
621impl Drop for CgroupFreezeGuard {
622    fn drop(&mut self) {
623        if self.active && self.path.exists() {
624            if let Err(e) = Cgroup::write_value_at(&self.path, "cgroup.freeze", "0")
625                .and_then(|_| Cgroup::wait_for_frozen_state(&self.path, false))
626            {
627                warn!("Failed to thaw cgroup {:?}: {}", self.path, e);
628            }
629            self.active = false;
630        }
631    }
632}
633
634impl Drop for Cgroup {
635    fn drop(&mut self) {
636        if !self.state.is_terminal() && self.path.exists() {
637            let froze = self.set_frozen(true).unwrap_or(false);
638            let _ = self.kill_all_processes();
639            let _ = self.wait_until_empty();
640            let _ = fs::remove_dir(&self.path);
641            if self.path.exists() && froze {
642                let _ = self.set_frozen(false);
643            }
644        }
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use std::ffi::OsString;
652    use std::os::unix::fs::symlink;
653    use std::sync::Mutex;
654
655    static CGROUP_ENV_LOCK: Mutex<()> = Mutex::new(());
656
657    #[test]
658    fn test_resource_limits_unlimited() {
659        let limits = ResourceLimits::unlimited();
660        assert!(limits.memory_bytes.is_none());
661        assert!(limits.memory_high.is_none());
662        assert!(limits.memory_swap_max.is_none());
663        assert!(limits.cpu_quota_us.is_none());
664        assert!(limits.cpu_weight.is_none());
665        assert!(limits.pids_max.is_none());
666        assert!(limits.io_limits.is_empty());
667    }
668
669    #[test]
670    fn test_cgroup_root_override_requires_absolute_path() {
671        assert_eq!(
672            Cgroup::root_path_from_override(None).unwrap(),
673            PathBuf::from(CGROUP_V2_ROOT)
674        );
675        assert_eq!(
676            Cgroup::root_path_from_override(Some(OsString::from(""))).unwrap(),
677            PathBuf::from(CGROUP_V2_ROOT)
678        );
679        assert_eq!(
680            Cgroup::root_path_from_override(Some(OsString::from("/sys/fs/cgroup/example.service")))
681                .unwrap(),
682            PathBuf::from("/sys/fs/cgroup/example.service")
683        );
684        assert!(Cgroup::root_path_from_override(Some(OsString::from("relative"))).is_err());
685    }
686
687    #[test]
688    fn test_cgroup_name_must_be_single_path_component() {
689        assert!(Cgroup::validate_cgroup_name("nucleus-abc123").is_ok());
690        assert!(Cgroup::validate_cgroup_name("").is_err());
691        assert!(Cgroup::validate_cgroup_name("../escape").is_err());
692        assert!(Cgroup::validate_cgroup_name("/sys/fs/cgroup/escape").is_err());
693        assert!(Cgroup::validate_cgroup_name("parent/child").is_err());
694    }
695
696    #[test]
697    fn test_parse_cgroup_events_bool_reads_frozen_state() {
698        let events = "populated 1\nfrozen 0\n";
699        assert!(!Cgroup::parse_cgroup_events_bool(events, "frozen").unwrap());
700        assert!(Cgroup::parse_cgroup_events_bool("frozen 1\n", "frozen").unwrap());
701        assert!(Cgroup::parse_cgroup_events_bool("frozen 2\n", "frozen").is_err());
702        assert!(Cgroup::parse_cgroup_events_bool("populated 1\n", "frozen").is_err());
703    }
704
705    #[test]
706    fn test_cgroup_root_validation_rejects_regular_filesystem() {
707        let temp = tempfile::tempdir().unwrap();
708
709        assert!(Cgroup::validate_root_path(temp.path()).is_err());
710    }
711
712    #[test]
713    fn test_create_rejects_regular_filesystem_root_before_child_creation() {
714        let temp = tempfile::tempdir().unwrap();
715        let child = temp.path().join("nucleus-bypass");
716
717        assert!(Cgroup::create_in_root("nucleus-bypass", temp.path()).is_err());
718        assert!(
719            !child.exists(),
720            "regular filesystem root must be rejected before creating a fake cgroup"
721        );
722    }
723
724    #[test]
725    fn test_cgroup_root_env_rejects_regular_filesystem() {
726        let _guard = CGROUP_ENV_LOCK.lock().unwrap();
727        let previous = std::env::var_os(NUCLEUS_CGROUP_ROOT_ENV);
728        let temp = tempfile::tempdir().unwrap();
729        let child = temp.path().join("nucleus-bypass");
730
731        std::env::set_var(NUCLEUS_CGROUP_ROOT_ENV, temp.path().as_os_str());
732        let result = Cgroup::create("nucleus-bypass");
733        match previous {
734            Some(value) => std::env::set_var(NUCLEUS_CGROUP_ROOT_ENV, value),
735            None => std::env::remove_var(NUCLEUS_CGROUP_ROOT_ENV),
736        }
737
738        assert!(result.is_err());
739        assert!(
740            !child.exists(),
741            "regular filesystem override must be rejected before creating a fake cgroup"
742        );
743    }
744
745    #[test]
746    fn test_write_value_rejects_preexisting_regular_file() {
747        let temp = tempfile::tempdir().unwrap();
748        let path = temp.path().join("fake-cgroup");
749        fs::create_dir(&path).unwrap();
750        let control_file = path.join("memory.max");
751        fs::write(&control_file, "old").unwrap();
752
753        let cgroup = Cgroup {
754            path,
755            state: CgroupState::Removed,
756        };
757        assert!(cgroup.write_value("memory.max", "123").is_err());
758        assert_eq!(fs::read_to_string(control_file).unwrap(), "old");
759    }
760
761    #[test]
762    fn test_write_value_rejects_symlink_control_file() {
763        let temp = tempfile::tempdir().unwrap();
764        let path = temp.path().join("fake-cgroup");
765        let target = temp.path().join("target");
766        fs::create_dir(&path).unwrap();
767        fs::write(&target, "old").unwrap();
768        symlink(&target, path.join("memory.max")).unwrap();
769
770        let cgroup = Cgroup {
771            path,
772            state: CgroupState::Removed,
773        };
774        assert!(cgroup.write_value("memory.max", "123").is_err());
775        assert_eq!(fs::read_to_string(target).unwrap(), "old");
776    }
777
778    // Note: Testing actual cgroup operations requires root privileges
779    // and cgroup v2 filesystem. These are tested in integration tests.
780
781    #[test]
782    fn test_cleanup_sets_removed_only_after_success() {
783        // BUG-06: cleanup must not mark state as Removed before the directory
784        // is actually removed. Verify structurally by brace-matching the
785        // function body instead of using a fragile char-window offset.
786        let source = include_str!("cgroup.rs");
787        let fn_start = source.find("pub fn cleanup").unwrap();
788        let after = &source[fn_start..];
789        let open = after.find('{').unwrap();
790        let mut depth = 0u32;
791        let mut fn_end = open;
792        for (i, ch) in after[open..].char_indices() {
793            match ch {
794                '{' => depth += 1,
795                '}' => {
796                    depth -= 1;
797                    if depth == 0 {
798                        fn_end = open + i + 1;
799                        break;
800                    }
801                }
802                _ => {}
803            }
804        }
805        let cleanup_body = &after[..fn_end];
806        let removed_pos = cleanup_body
807            .find("Removed")
808            .expect("must reference Removed state");
809        let remove_dir_pos = cleanup_body
810            .find("remove_dir")
811            .expect("must call remove_dir");
812        assert!(
813            removed_pos > remove_dir_pos,
814            "CgroupState::Removed must be set AFTER remove_dir succeeds, not before"
815        );
816    }
817
818    #[test]
819    fn test_parse_cgroup_events_populated() {
820        assert!(Cgroup::parse_cgroup_events_populated("populated 1\nfrozen 0\n").unwrap());
821        assert!(!Cgroup::parse_cgroup_events_populated("frozen 0\npopulated 0\n").unwrap());
822    }
823
824    #[test]
825    fn test_set_limits_source_enables_memory_oom_group() {
826        let source = include_str!("cgroup.rs");
827        let fn_start = source.find("pub fn set_limits").unwrap();
828        let after = &source[fn_start..];
829        let open = after.find('{').unwrap();
830        let mut depth = 0u32;
831        let mut fn_end = open;
832        for (i, ch) in after[open..].char_indices() {
833            match ch {
834                '{' => depth += 1,
835                '}' => {
836                    depth -= 1;
837                    if depth == 0 {
838                        fn_end = open + i + 1;
839                        break;
840                    }
841                }
842                _ => {}
843            }
844        }
845        let body = &after[..fn_end];
846        assert!(
847            body.contains("memory.oom.group"),
848            "set_limits must enable memory.oom.group when memory controls are configured"
849        );
850    }
851
852    #[test]
853    fn test_cleanup_source_kills_processes_before_remove_dir() {
854        let source = include_str!("cgroup.rs");
855        let fn_start = source.find("pub fn cleanup").unwrap();
856        let after = &source[fn_start..];
857        let open = after.find('{').unwrap();
858        let mut depth = 0u32;
859        let mut fn_end = open;
860        for (i, ch) in after[open..].char_indices() {
861            match ch {
862                '{' => depth += 1,
863                '}' => {
864                    depth -= 1;
865                    if depth == 0 {
866                        fn_end = open + i + 1;
867                        break;
868                    }
869                }
870                _ => {}
871            }
872        }
873        let body = &after[..fn_end];
874        let freeze_pos = body.find("set_frozen(true)").unwrap();
875        let kill_pos = body.find("kill_all_processes").unwrap();
876        let remove_dir_pos = body.find("remove_dir").unwrap();
877        assert!(
878            freeze_pos < kill_pos && kill_pos < remove_dir_pos,
879            "cleanup must freeze and kill the cgroup before attempting remove_dir"
880        );
881    }
882}