1use std::collections::HashSet;
4use std::fs::File;
5use std::io::{BufRead, BufReader};
6use std::path::{Component, Path, PathBuf};
7
8use anyhow::{bail, Context, Result};
9
10pub(crate) struct CgroupReader {
12 mount_point: PathBuf,
13}
14
15impl CgroupReader {
16 pub(crate) fn discover() -> Result<Self> {
18 let file = File::open("/proc/self/mountinfo").context("open /proc/self/mountinfo")?;
19 let mount_point =
20 parse_cgroup2_mount(BufReader::new(file))?.context("root cgroup v2 mount not found")?;
21 Ok(Self { mount_point })
22 }
23
24 pub(crate) fn process_cgroup(&self, tgid: u32) -> Result<Option<PathBuf>> {
26 let path = format!("/proc/{tgid}/cgroup");
27 let file = File::open(&path).with_context(|| format!("open {path}"))?;
28 let cgroup_path = parse_process_cgroup(BufReader::new(file))?;
29 let Some(relative) = relative_cgroup_path(&cgroup_path)? else {
30 return Ok(None);
31 };
32 Ok(Some(self.mount_point.join(relative)))
33 }
34
35 pub(crate) fn processes(&self, cgroup: &Path) -> Result<HashSet<u32>> {
37 let type_path = cgroup.join("cgroup.type");
38 let cgroup_type = std::fs::read_to_string(&type_path)
39 .with_context(|| format!("read {}", type_path.display()))?;
40 if cgroup_type.trim_end() != "domain" {
41 bail!(
42 "cgroup {} is not an exact domain: unsupported cgroup type {:?}",
43 cgroup.display(),
44 cgroup_type.trim_end()
45 );
46 }
47
48 let path = cgroup.join("cgroup.procs");
49 let file = File::open(&path).with_context(|| format!("open {}", path.display()))?;
50 parse_cgroup_procs(BufReader::new(file))
51 }
52}
53
54fn parse_process_cgroup(reader: impl BufRead) -> Result<PathBuf> {
55 let mut unified = None;
56
57 for line in reader.lines() {
58 let line = line.context("read process cgroup entry")?;
59 let Some(path) = line.strip_prefix("0::") else {
60 continue;
61 };
62 if unified.is_some() {
63 bail!("multiple cgroup v2 entries");
64 }
65 if path.ends_with(" (deleted)") {
66 bail!("cgroup was deleted");
67 }
68 unified = Some(PathBuf::from(path));
69 }
70
71 unified.context("cgroup v2 entry not found")
72}
73
74fn relative_cgroup_path(path: &Path) -> Result<Option<PathBuf>> {
75 if !path.is_absolute() {
76 bail!("cgroup path is not absolute: {}", path.display());
77 }
78 let relative = path
79 .strip_prefix(Path::new("/"))
80 .context("strip cgroup root")?;
81 if relative.as_os_str().is_empty() {
82 return Ok(None);
83 }
84 if relative
85 .components()
86 .any(|component| !matches!(component, Component::Normal(_)))
87 {
88 bail!("invalid cgroup path: {}", path.display());
89 }
90 Ok(Some(relative.to_path_buf()))
91}
92
93fn parse_cgroup_procs(reader: impl BufRead) -> Result<HashSet<u32>> {
94 let mut processes = HashSet::new();
95
96 for line in reader.lines() {
97 let line = line.context("read cgroup.procs entry")?;
98 let value = line.trim();
99 if value.is_empty() {
100 continue;
101 }
102 let tgid = value
103 .parse::<u32>()
104 .with_context(|| format!("invalid process ID in cgroup.procs: {value}"))?;
105 if tgid == 0 {
106 bail!("invalid process ID in cgroup.procs: 0");
107 }
108 processes.insert(tgid);
109 }
110
111 Ok(processes)
112}
113
114fn parse_cgroup2_mount(reader: impl BufRead) -> Result<Option<PathBuf>> {
115 for line in reader.lines() {
116 let line = line.context("read mountinfo entry")?;
117 let Some((mount_fields, fs_fields)) = line.split_once(" - ") else {
118 continue;
119 };
120 let mount_fields: Vec<_> = mount_fields.split_whitespace().collect();
121 let mut fs_fields = fs_fields.split_whitespace();
122 if mount_fields.len() < 5 || fs_fields.next() != Some("cgroup2") {
123 continue;
124 }
125 if mount_fields[3] != "/" {
128 continue;
129 }
130 return Ok(Some(decode_mount_path(mount_fields[4])?));
131 }
132
133 Ok(None)
134}
135
136fn decode_mount_path(value: &str) -> Result<PathBuf> {
137 let mut input = value.chars();
138 let mut output = String::with_capacity(value.len());
139
140 while let Some(character) = input.next() {
141 if character != '\\' {
142 output.push(character);
143 continue;
144 }
145
146 let mut digits = [0u16; 3];
147 for digit in &mut digits {
148 let Some(character @ '0'..='7') = input.next() else {
149 bail!("invalid mountinfo path escape: {value}");
150 };
151 *digit = u16::from(character as u8 - b'0');
152 }
153 let decoded = (digits[0] << 6) | (digits[1] << 3) | digits[2];
154 if decoded > u16::from(u8::MAX) {
155 bail!("mountinfo path escape is out of range: {value}");
156 }
157 let decoded = decoded as u8;
158 if !decoded.is_ascii() {
159 bail!("non-ASCII mountinfo path escape is unsupported: {value}");
160 }
161 output.push(char::from(decoded));
162 }
163
164 Ok(PathBuf::from(output))
165}
166
167#[cfg(test)]
168mod tests {
169 use std::io::Cursor;
170
171 use super::*;
172
173 #[test]
174 fn parses_unified_cgroup_entry() {
175 let input = b"7:cpu:/legacy\n0::/services/inference\n";
176 assert_eq!(
177 parse_process_cgroup(Cursor::new(input)).unwrap(),
178 PathBuf::from("/services/inference")
179 );
180 }
181
182 #[test]
183 fn rejects_deleted_or_missing_cgroup() {
184 assert!(parse_process_cgroup(Cursor::new(b"0::/gone (deleted)\n")).is_err());
185 assert!(parse_process_cgroup(Cursor::new(b"7:cpu:/legacy\n")).is_err());
186 assert!(parse_process_cgroup(Cursor::new(b"0::/one\n0::/two\n")).is_err());
187 }
188
189 #[test]
190 fn rejects_root_and_unsafe_paths() {
191 assert_eq!(relative_cgroup_path(Path::new("/")).unwrap(), None);
192 assert!(relative_cgroup_path(Path::new("relative")).is_err());
193 assert!(relative_cgroup_path(Path::new("/safe/../unsafe")).is_err());
194 }
195
196 #[test]
197 fn parses_cgroup2_mount_and_escapes() {
198 let input = b"10 1 0:1 / /old rw - cgroup cgroup rw\n\
199 39 30 0:33 / /sys/fs/cgroup\\040root rw - cgroup2 cgroup2 rw\n";
200 assert_eq!(
201 parse_cgroup2_mount(Cursor::new(input)).unwrap(),
202 Some(PathBuf::from("/sys/fs/cgroup root"))
203 );
204 }
205
206 #[test]
207 fn decodes_utf8_mount_path() {
208 assert_eq!(
209 decode_mount_path("/sys/fs/cgroup-é\\040root").unwrap(),
210 PathBuf::from("/sys/fs/cgroup-é root")
211 );
212 assert!(decode_mount_path("/sys/fs/cgroup\\377root").is_err());
213 }
214
215 #[test]
216 fn ignores_non_root_cgroup2_mount() {
217 let input = b"39 30 0:33 /slice /sys/fs/cgroup rw - cgroup2 cgroup2 rw\n";
218 assert_eq!(parse_cgroup2_mount(Cursor::new(input)).unwrap(), None);
219 }
220
221 #[test]
222 fn parses_and_deduplicates_cgroup_procs() {
223 let input = b"10\n20\n10\n";
224 assert_eq!(
225 parse_cgroup_procs(Cursor::new(input)).unwrap(),
226 HashSet::from([10, 20])
227 );
228 assert!(parse_cgroup_procs(Cursor::new(b"not-a-pid\n")).is_err());
229 }
230}