Skip to main content

rlmctl_core/
process.rs

1use common::{Error, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6/// Basic process info
7#[derive(Clone, Debug, Default, PartialEq, Eq)]
8pub struct ProcessInfo {
9    pub pid: u32,
10    /// comm, from /proc/<pid>/status `Name:` (kernel-truncated to 15 chars).
11    pub name: String,
12    pub ppid: Option<u32>,
13    pub session: Option<u32>,
14    pub executable: Option<PathBuf>,
15    /// Real uid, from /proc/<pid>/status `Uid:`.
16    pub uid: u32,
17    /// VmRSS + VmSwap, in KB.
18    pub rss_kb: u64,
19    /// The v2 ("0::") cgroup path from /proc/<pid>/cgroup, if readable.
20    pub cgroup: Option<String>,
21}
22
23impl ProcessInfo {
24    /// Basename of `executable`, if set and valid UTF-8, without the
25    /// ` (deleted)` suffix the kernel adds after the binary was replaced
26    /// (e.g. by a package upgrade).
27    pub fn exe_name(&self) -> Option<&str> {
28        let name = self.executable.as_deref()?.file_name()?.to_str()?;
29        Some(name.strip_suffix(" (deleted)").unwrap_or(name))
30    }
31
32    /// The executable's basename when readable (not truncated by the
33    /// kernel); falls back to `name` (comm) otherwise.
34    pub fn display_name(&self) -> &str {
35        self.exe_name().unwrap_or(&self.name)
36    }
37}
38
39/// The fields of /proc/<pid>/status this crate cares about.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct StatusFields {
42    pub uid: u32,
43    pub name: String,
44    pub rss_kb: u64,
45}
46
47/// Parse `/proc/<pid>/status`.
48///
49/// - `Uid:` line is `Uid:\t<real>\t<effective>\t<saved>\t<fs>`; we take the
50///   first (real) field.
51/// - `Name:` is the comm, truncated to 15 chars by the kernel; that is fine:
52///   it matches the protect-list which also compares against comm.
53/// - `VmSwap:` may be absent (e.g. kernel thread / no swap) and then counts as 0.
54///
55/// Returns `None` only if the required `Uid:` or `Name:` lines are missing.
56pub fn parse_status(status: &str) -> Option<StatusFields> {
57    let mut uid = None;
58    let mut name = None;
59    let mut vm_rss = 0u64;
60    let mut vm_swap = 0u64;
61
62    for line in status.lines() {
63        if let Some(rest) = line.strip_prefix("Name:") {
64            name = Some(rest.trim().to_string());
65        } else if let Some(rest) = line.strip_prefix("Uid:") {
66            // First whitespace-separated field is the real uid.
67            uid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
68        } else if let Some(rest) = line.strip_prefix("VmRSS:") {
69            vm_rss = first_kb(rest).unwrap_or(0);
70        } else if let Some(rest) = line.strip_prefix("VmSwap:") {
71            vm_swap = first_kb(rest).unwrap_or(0);
72        }
73    }
74
75    Some(StatusFields {
76        uid: uid?,
77        name: name?,
78        rss_kb: vm_rss.saturating_add(vm_swap),
79    })
80}
81
82/// Parse the leading integer of a `"   1234 kB"` style value as a kB count.
83fn first_kb(rest: &str) -> Option<u64> {
84    rest.split_whitespace().next()?.parse().ok()
85}
86
87/// Parse the v2 line of /proc/<pid>/cgroup ("0::<path>"). Hybrid-mode lines
88/// for other controllers are noise and skipped.
89pub fn parse_cgroup_v2(content: &str) -> Option<String> {
90    content
91        .lines()
92        .find_map(|l| l.strip_prefix("0::").map(|p| p.to_string()))
93}
94
95/// The calling process's real uid.
96pub fn current_uid() -> u32 {
97    // SAFETY: getuid() is always safe; it only reads our real UID.
98    unsafe { libc::getuid() }
99}
100
101/// Extended process info with grouping information
102pub struct ProcessGroup {
103    pub name: String,
104    pub executable: Option<PathBuf>,
105    pub processes: Vec<ProcessInfo>,
106}
107
108impl ProcessGroup {
109    /// Memory of all processes in the group (RSS + swap), in KB.
110    pub fn rss_kb(&self) -> u64 {
111        self.processes.iter().map(|p| p.rss_kb).sum()
112    }
113}
114
115/// Read process stat file to get PPID and session
116fn read_process_stat(proc_path: &Path) -> Option<(u32, u32)> {
117    parse_ppid_session(&fs::read_to_string(proc_path.join("stat")).ok()?)
118}
119
120/// PPID (field 4) and session (field 6) from the text of `/proc/<pid>/stat`.
121/// Fields are counted from the last ')', since the comm may hold spaces.
122fn parse_ppid_session(stat: &str) -> Option<(u32, u32)> {
123    let mut fields = stat[stat.rfind(')')? + 1..].split_whitespace();
124    // After the comm: state, ppid, pgrp, session.
125    let ppid = fields.nth(1)?.parse().ok()?;
126    let session = fields.nth(1)?.parse().ok()?;
127    Some((ppid, session))
128}
129
130/// The start time (field 22, clock ticks after boot) from the text of
131/// `/proc/<pid>/stat`. The comm field is in parentheses and may itself hold
132/// spaces or ')', so fields are counted from the last ')'.
133pub fn parse_start_time(stat: &str) -> Option<u64> {
134    let rest = &stat[stat.rfind(')')? + 1..];
135    // After the comm comes field 3 (state); field 22 is 19 further on.
136    rest.split_whitespace().nth(19)?.parse().ok()
137}
138
139/// When `pid` started, or `None` if it is gone. With the PID it names one
140/// process: a PID reused later by another process has a different start
141/// time.
142pub fn start_time(pid: u32) -> Option<u64> {
143    parse_start_time(&fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)
144}
145
146/// Get executable path for a process
147fn get_executable(proc_path: &Path) -> Option<PathBuf> {
148    fs::read_link(proc_path.join("exe")).ok()
149}
150
151/// Read the full `ProcessInfo` snapshot for one pid. `status` is required
152/// (its `Uid:`/`Name:` fields anchor the snapshot); `stat`, `exe`, and
153/// `cgroup` are each best-effort and simply left at their default/`None` if
154/// unreadable (e.g. the process exited mid-read, or `exe` requires
155/// permissions we don't have). Returns `None` only if `status` can't be read
156/// or parsed.
157pub fn read_process(pid: u32) -> Option<ProcessInfo> {
158    let proc_path = Path::new("/proc").join(pid.to_string());
159
160    let status = fs::read_to_string(proc_path.join("status")).ok()?;
161    let fields = parse_status(&status)?;
162
163    let (ppid, session) = read_process_stat(&proc_path).unwrap_or((0, 0));
164    let executable = get_executable(&proc_path);
165    let cgroup = fs::read_to_string(proc_path.join("cgroup"))
166        .ok()
167        .and_then(|c| parse_cgroup_v2(&c));
168
169    Some(ProcessInfo {
170        pid,
171        name: fields.name,
172        ppid: if ppid > 0 { Some(ppid) } else { None },
173        session: if session > 0 { Some(session) } else { None },
174        executable,
175        uid: fields.uid,
176        rss_kb: fields.rss_kb,
177        cgroup,
178    })
179}
180
181/// List all running processes with extended information
182pub fn list_all() -> Result<Vec<ProcessInfo>> {
183    let mut processes = Vec::new();
184
185    for entry in fs::read_dir("/proc")?.flatten() {
186        let Some(pid) = entry
187            .file_name()
188            .to_str()
189            .and_then(|n| n.parse::<u32>().ok())
190        else {
191            continue;
192        };
193
194        if let Some(p) = read_process(pid) {
195            processes.push(p);
196        }
197    }
198
199    processes.sort_by(|a, b| a.name.cmp(&b.name));
200    Ok(processes)
201}
202
203/// List processes owned by `uid`. A cheap `stat()` pre-filter (comparing the
204/// `/proc/<pid>` directory's owning uid) skips reading any file belonging to
205/// another user's process before `read_process` opens `status`/`stat`/`exe`.
206pub fn list_for_uid(uid: u32) -> Result<Vec<ProcessInfo>> {
207    use std::os::unix::fs::MetadataExt;
208    let mut out = Vec::new();
209    for entry in fs::read_dir("/proc")?.flatten() {
210        let Some(pid) = entry
211            .file_name()
212            .to_str()
213            .and_then(|n| n.parse::<u32>().ok())
214        else {
215            continue;
216        };
217        // Cheap pre-filter: one stat() before reading any file of another user's process.
218        if entry.metadata().ok().map(|m| m.uid()) != Some(uid) {
219            continue;
220        }
221        if let Some(p) = read_process(pid) {
222            if p.uid == uid {
223                out.push(p);
224            }
225        }
226    }
227    Ok(out)
228}
229
230/// Find all PIDs matching a process name
231pub fn find_by_name(name: &str) -> Result<Vec<u32>> {
232    let mut pids = Vec::new();
233
234    for entry in fs::read_dir("/proc")? {
235        let entry = entry?;
236        let path = entry.path();
237
238        // Only look at numeric directories (PIDs)
239        let Some(pid_str) = path.file_name().and_then(|n| n.to_str()) else {
240            continue;
241        };
242        let Ok(pid) = pid_str.parse::<u32>() else {
243            continue;
244        };
245
246        if matches_name(&path, name) {
247            pids.push(pid);
248        }
249    }
250
251    if pids.is_empty() {
252        return Err(Error::ProcessNameNotFound(name.to_string()));
253    }
254
255    Ok(pids)
256}
257
258fn matches_name(proc_path: &Path, name: &str) -> bool {
259    // Try /proc/PID/comm first (max 15 chars, may be truncated)
260    if let Ok(comm) = fs::read_to_string(proc_path.join("comm")) {
261        let comm = comm.trim();
262        if comm == name {
263            return true;
264        }
265        // comm is 15 chars (possibly truncated) and name is longer - verify via exe
266        if comm.len() == 15 && name.len() > 15 && name.starts_with(comm) {
267            if let Ok(exe) = fs::read_link(proc_path.join("exe")) {
268                if let Some(exe_name) = exe.file_name().and_then(|n| n.to_str()) {
269                    return exe_name == name;
270                }
271            }
272        }
273    }
274
275    // Try /proc/PID/exe symlink (full path)
276    if let Ok(exe) = fs::read_link(proc_path.join("exe")) {
277        if let Some(exe_name) = exe.file_name().and_then(|n| n.to_str()) {
278            if exe_name == name {
279                return true;
280            }
281        }
282    }
283
284    false
285}
286
287/// Result of [`find_by_name_for_uid`]: matches owned by the requested uid,
288/// plus a count of matches owned by other users (so the caller can tell "no
289/// such process" apart from "that process belongs to someone else").
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct NameMatches {
292    pub pids: Vec<u32>,
293    pub other_users: usize,
294}
295
296/// Find all PIDs matching `name`, split by ownership. Only errors
297/// (`ProcessNameNotFound`) when there are no matches at all, own-uid or
298/// otherwise.
299pub fn find_by_name_for_uid(name: &str, uid: u32) -> Result<NameMatches> {
300    use std::os::unix::fs::MetadataExt;
301
302    let mut pids = Vec::new();
303    let mut other_users = 0usize;
304
305    for entry in fs::read_dir("/proc")?.flatten() {
306        let path = entry.path();
307
308        let Some(pid) = path
309            .file_name()
310            .and_then(|n| n.to_str())
311            .and_then(|n| n.parse::<u32>().ok())
312        else {
313            continue;
314        };
315
316        if !matches_name(&path, name) {
317            continue;
318        }
319
320        let Ok(owner_uid) = entry.metadata().map(|m| m.uid()) else {
321            continue;
322        };
323
324        if owner_uid == uid {
325            pids.push(pid);
326        } else {
327            other_users += 1;
328        }
329    }
330
331    if pids.is_empty() && other_users == 0 {
332        return Err(Error::ProcessNameNotFound(name.to_string()));
333    }
334
335    Ok(NameMatches { pids, other_users })
336}
337
338/// Group processes by executable basename (same application), largest
339/// memory first. Apps with a single process get a group of their own.
340pub fn group_by_executable(processes: &[ProcessInfo]) -> Vec<ProcessGroup> {
341    let mut groups: HashMap<String, Vec<ProcessInfo>> = HashMap::new();
342
343    for proc in processes {
344        let key = proc
345            .executable
346            .as_ref()
347            .and_then(|exe| exe.file_name())
348            .and_then(|n| n.to_str())
349            .map(String::from)
350            .unwrap_or_else(|| proc.name.clone());
351
352        groups.entry(key).or_default().push(proc.clone());
353    }
354
355    let mut groups: Vec<ProcessGroup> = groups
356        .into_iter()
357        .map(|(name, procs)| {
358            let executable = procs.first().and_then(|p| p.executable.clone());
359            ProcessGroup {
360                name,
361                executable,
362                processes: procs,
363            }
364        })
365        .collect();
366    // Biggest memory users first: those are the apps worth limiting.
367    groups.sort_by(|a, b| {
368        b.rss_kb()
369            .cmp(&a.rss_kb())
370            .then_with(|| a.name.cmp(&b.name))
371    });
372    groups
373}
374
375/// Group processes by session ID (same process group)
376pub fn group_by_session(processes: &[ProcessInfo]) -> Vec<ProcessGroup> {
377    let mut groups: HashMap<u32, Vec<ProcessInfo>> = HashMap::new();
378
379    for proc in processes {
380        if let Some(session) = proc.session {
381            groups.entry(session).or_default().push(proc.clone());
382        }
383    }
384
385    groups
386        .into_iter()
387        .map(|(session_id, procs)| {
388            let name = procs
389                .first()
390                .map(|p| format!("{} (session {})", p.name, session_id))
391                .unwrap_or_else(|| format!("Session {}", session_id));
392            let executable = procs.first().and_then(|p| p.executable.clone());
393            ProcessGroup {
394                name,
395                executable,
396                processes: procs,
397            }
398        })
399        .filter(|group| group.processes.len() > 1)
400        .collect()
401}
402
403/// Find all processes that share the same parent process tree
404/// Returns processes that are descendants of the given PID
405pub fn find_process_tree(root_pid: u32) -> Result<Vec<u32>> {
406    let all_processes = list_all()?;
407    let mut result = vec![root_pid];
408    let mut to_check = vec![root_pid];
409    let mut checked = std::collections::HashSet::new();
410    checked.insert(root_pid);
411
412    while let Some(pid) = to_check.pop() {
413        // Find all processes with this PID as parent
414        for proc in &all_processes {
415            if let Some(ppid) = proc.ppid {
416                if ppid == pid && !checked.contains(&proc.pid) {
417                    result.push(proc.pid);
418                    to_check.push(proc.pid);
419                    checked.insert(proc.pid);
420                }
421            }
422        }
423    }
424
425    Ok(result)
426}
427
428/// Find all processes matching an executable name (all instances)
429pub fn find_all_by_executable(executable_name: &str) -> Result<Vec<ProcessInfo>> {
430    let all = list_all()?;
431    let mut matches = Vec::new();
432
433    for proc in all {
434        let matches_name = proc.name == executable_name
435            || proc
436                .executable
437                .as_ref()
438                .and_then(|exe| exe.file_name())
439                .and_then(|n| n.to_str())
440                .map(|n| n == executable_name)
441                .unwrap_or(false);
442
443        if matches_name {
444            matches.push(proc);
445        }
446    }
447
448    if matches.is_empty() {
449        return Err(Error::ProcessNameNotFound(executable_name.to_string()));
450    }
451
452    Ok(matches)
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn start_time_is_field_22_counted_after_the_comm() {
461        let tail = "R 2238197 2238197 2238197 0 -1 4194304 519 0 0 0 0 0 0 0 20 0 1 0 5852809 18214912 1841";
462        assert_eq!(
463            parse_start_time(&format!("2238200 (cat) {tail}")),
464            Some(5852809)
465        );
466        // A comm with spaces and a ')' of its own.
467        assert_eq!(
468            parse_start_time(&format!("42 (Web Co) (x)) {tail}")),
469            Some(5852809)
470        );
471        assert_eq!(parse_start_time("42 (cat) R 1 2"), None);
472        assert_eq!(parse_start_time(""), None);
473        assert!(start_time(std::process::id()).is_some());
474    }
475
476    #[test]
477    fn parse_status_reads_uid_name_and_rss_plus_swap() {
478        let s = "Name:\tIsolated Web Co\nUid:\t1000\t1000\t1000\t1000\nVmRSS:\t  500000 kB\nVmSwap:\t   2000 kB\n";
479        assert_eq!(
480            parse_status(s),
481            Some(StatusFields {
482                uid: 1000,
483                name: "Isolated Web Co".into(),
484                rss_kb: 502_000
485            })
486        );
487    }
488
489    #[test]
490    fn parse_status_requires_uid_and_name() {
491        assert_eq!(parse_status("VmRSS:\t1 kB\n"), None);
492        assert_eq!(parse_status("Name:\tx\n"), None);
493    }
494
495    #[test]
496    fn parse_cgroup_v2_skips_hybrid_lines() {
497        assert_eq!(
498            parse_cgroup_v2("1:name=systemd:/foo\n0::/bar\n"),
499            Some("/bar".into())
500        );
501        assert_eq!(parse_cgroup_v2(""), None);
502    }
503
504    #[test]
505    fn list_for_uid_contains_self_and_only_that_uid() {
506        let uid = current_uid();
507        let procs = list_for_uid(uid).unwrap();
508        let me = procs
509            .iter()
510            .find(|p| p.pid == std::process::id())
511            .expect("own process listed");
512        assert!(me.cgroup.is_some(), "cgroup path read");
513        assert!(me.rss_kb > 0, "rss read");
514        assert!(procs.iter().all(|p| p.uid == uid));
515    }
516
517    #[test]
518    fn display_name_prefers_exe_basename() {
519        let p = ProcessInfo {
520            name: "Isolated Web Co".into(),
521            executable: Some(PathBuf::from("/usr/lib/firefox/firefox")),
522            ..Default::default()
523        };
524        assert_eq!(p.display_name(), "firefox");
525        let q = ProcessInfo {
526            name: "kworker".into(),
527            ..Default::default()
528        };
529        assert_eq!(q.display_name(), "kworker");
530    }
531
532    #[test]
533    fn exe_name_ignores_deleted_suffix() {
534        let p = ProcessInfo {
535            executable: Some(PathBuf::from("/opt/google/chrome/chrome (deleted)")),
536            ..Default::default()
537        };
538        assert_eq!(p.exe_name(), Some("chrome"));
539    }
540
541    #[test]
542    fn groups_are_ordered_by_memory_then_name() {
543        let p = |pid: u32, exe: &str, rss_kb: u64| ProcessInfo {
544            pid,
545            name: exe.into(),
546            executable: Some(format!("/bin/{exe}").into()),
547            rss_kb,
548            ..Default::default()
549        };
550        let procs = vec![
551            p(1, "b", 100),
552            p(2, "b", 100),
553            p(3, "a", 150),
554            p(4, "a", 50),
555            p(5, "c", 10),
556            p(6, "c", 10),
557            p(7, "c", 10),
558            p(8, "d", 900),
559        ];
560        let groups = group_by_executable(&procs);
561        let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
562        // Single-process apps are listed too; equal memory sorts by name.
563        assert_eq!(names, vec!["d", "a", "b", "c"]);
564        assert_eq!(groups[0].rss_kb(), 900);
565        assert_eq!(groups[1].rss_kb(), 200);
566    }
567
568    #[test]
569    fn ppid_and_session_survive_a_comm_with_spaces() {
570        let stat = "4242 (Isolated Web Co) S 4100 4100 3000 0 -1 4194560";
571        assert_eq!(parse_ppid_session(stat), Some((4100, 3000)));
572        assert_eq!(parse_ppid_session("7 (a) b) R 1 7 7 0"), Some((1, 7)));
573        assert_eq!(parse_ppid_session("garbage"), None);
574    }
575}