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's fine,
52///   it matches the protect-list which also compares against comm.
53/// - `VmSwap:` may be absent (e.g. kernel thread / no swap) — treated 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
108/// Read process stat file to get PPID and session
109fn read_process_stat(proc_path: &Path) -> Option<(u32, u32)> {
110    // Format: pid comm state ppid pgrp session ...
111    // Fields: 0   1    2     3    4    5
112    if let Ok(content) = fs::read_to_string(proc_path.join("stat")) {
113        let parts: Vec<&str> = content.split_whitespace().collect();
114        if parts.len() >= 6 {
115            if let (Ok(ppid), Ok(session)) = (parts[3].parse(), parts[5].parse()) {
116                return Some((ppid, session));
117            }
118        }
119    }
120    None
121}
122
123/// Get executable path for a process
124fn get_executable(proc_path: &Path) -> Option<PathBuf> {
125    fs::read_link(proc_path.join("exe")).ok()
126}
127
128/// Read the full `ProcessInfo` snapshot for one pid. `status` is required
129/// (its `Uid:`/`Name:` fields anchor the snapshot); `stat`, `exe`, and
130/// `cgroup` are each best-effort and simply left at their default/`None` if
131/// unreadable (e.g. the process exited mid-read, or `exe` requires
132/// permissions we don't have). Returns `None` only if `status` can't be read
133/// or parsed.
134pub fn read_process(pid: u32) -> Option<ProcessInfo> {
135    let proc_path = Path::new("/proc").join(pid.to_string());
136
137    let status = fs::read_to_string(proc_path.join("status")).ok()?;
138    let fields = parse_status(&status)?;
139
140    let (ppid, session) = read_process_stat(&proc_path).unwrap_or((0, 0));
141    let executable = get_executable(&proc_path);
142    let cgroup = fs::read_to_string(proc_path.join("cgroup"))
143        .ok()
144        .and_then(|c| parse_cgroup_v2(&c));
145
146    Some(ProcessInfo {
147        pid,
148        name: fields.name,
149        ppid: if ppid > 0 { Some(ppid) } else { None },
150        session: if session > 0 { Some(session) } else { None },
151        executable,
152        uid: fields.uid,
153        rss_kb: fields.rss_kb,
154        cgroup,
155    })
156}
157
158/// List all running processes with extended information
159pub fn list_all() -> Result<Vec<ProcessInfo>> {
160    let mut processes = Vec::new();
161
162    for entry in fs::read_dir("/proc")?.flatten() {
163        let Some(pid) = entry
164            .file_name()
165            .to_str()
166            .and_then(|n| n.parse::<u32>().ok())
167        else {
168            continue;
169        };
170
171        if let Some(p) = read_process(pid) {
172            processes.push(p);
173        }
174    }
175
176    processes.sort_by(|a, b| a.name.cmp(&b.name));
177    Ok(processes)
178}
179
180/// List processes owned by `uid`. A cheap `stat()` pre-filter (comparing the
181/// `/proc/<pid>` directory's owning uid) skips reading any file belonging to
182/// another user's process before `read_process` opens `status`/`stat`/`exe`.
183pub fn list_for_uid(uid: u32) -> Result<Vec<ProcessInfo>> {
184    use std::os::unix::fs::MetadataExt;
185    let mut out = Vec::new();
186    for entry in fs::read_dir("/proc")?.flatten() {
187        let Some(pid) = entry
188            .file_name()
189            .to_str()
190            .and_then(|n| n.parse::<u32>().ok())
191        else {
192            continue;
193        };
194        // Cheap pre-filter: one stat() before reading any file of another user's process.
195        if entry.metadata().ok().map(|m| m.uid()) != Some(uid) {
196            continue;
197        }
198        if let Some(p) = read_process(pid) {
199            if p.uid == uid {
200                out.push(p);
201            }
202        }
203    }
204    Ok(out)
205}
206
207/// Find all PIDs matching a process name
208pub fn find_by_name(name: &str) -> Result<Vec<u32>> {
209    let mut pids = Vec::new();
210
211    for entry in fs::read_dir("/proc")? {
212        let entry = entry?;
213        let path = entry.path();
214
215        // Only look at numeric directories (PIDs)
216        let Some(pid_str) = path.file_name().and_then(|n| n.to_str()) else {
217            continue;
218        };
219        let Ok(pid) = pid_str.parse::<u32>() else {
220            continue;
221        };
222
223        if matches_name(&path, name) {
224            pids.push(pid);
225        }
226    }
227
228    if pids.is_empty() {
229        return Err(Error::ProcessNameNotFound(name.to_string()));
230    }
231
232    Ok(pids)
233}
234
235fn matches_name(proc_path: &Path, name: &str) -> bool {
236    // Try /proc/PID/comm first (max 15 chars, may be truncated)
237    if let Ok(comm) = fs::read_to_string(proc_path.join("comm")) {
238        let comm = comm.trim();
239        if comm == name {
240            return true;
241        }
242        // comm is 15 chars (possibly truncated) and name is longer - verify via exe
243        if comm.len() == 15 && name.len() > 15 && name.starts_with(comm) {
244            if let Ok(exe) = fs::read_link(proc_path.join("exe")) {
245                if let Some(exe_name) = exe.file_name().and_then(|n| n.to_str()) {
246                    return exe_name == name;
247                }
248            }
249        }
250    }
251
252    // Try /proc/PID/exe symlink (full path)
253    if let Ok(exe) = fs::read_link(proc_path.join("exe")) {
254        if let Some(exe_name) = exe.file_name().and_then(|n| n.to_str()) {
255            if exe_name == name {
256                return true;
257            }
258        }
259    }
260
261    false
262}
263
264/// Result of [`find_by_name_for_uid`]: matches owned by the requested uid,
265/// plus a count of matches owned by other users (so the caller can tell "no
266/// such process" apart from "that process belongs to someone else").
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct NameMatches {
269    pub pids: Vec<u32>,
270    pub other_users: usize,
271}
272
273/// Find all PIDs matching `name`, split by ownership. Only errors
274/// (`ProcessNameNotFound`) when there are no matches at all, own-uid or
275/// otherwise.
276pub fn find_by_name_for_uid(name: &str, uid: u32) -> Result<NameMatches> {
277    use std::os::unix::fs::MetadataExt;
278
279    let mut pids = Vec::new();
280    let mut other_users = 0usize;
281
282    for entry in fs::read_dir("/proc")?.flatten() {
283        let path = entry.path();
284
285        let Some(pid) = path
286            .file_name()
287            .and_then(|n| n.to_str())
288            .and_then(|n| n.parse::<u32>().ok())
289        else {
290            continue;
291        };
292
293        if !matches_name(&path, name) {
294            continue;
295        }
296
297        let Ok(owner_uid) = entry.metadata().map(|m| m.uid()) else {
298            continue;
299        };
300
301        if owner_uid == uid {
302            pids.push(pid);
303        } else {
304            other_users += 1;
305        }
306    }
307
308    if pids.is_empty() && other_users == 0 {
309        return Err(Error::ProcessNameNotFound(name.to_string()));
310    }
311
312    Ok(NameMatches { pids, other_users })
313}
314
315/// Group processes by executable path (same application)
316pub fn group_by_executable(processes: &[ProcessInfo]) -> Vec<ProcessGroup> {
317    let mut groups: HashMap<String, Vec<ProcessInfo>> = HashMap::new();
318
319    for proc in processes {
320        let key = proc
321            .executable
322            .as_ref()
323            .and_then(|exe| exe.file_name())
324            .and_then(|n| n.to_str())
325            .map(String::from)
326            .unwrap_or_else(|| proc.name.clone());
327
328        groups.entry(key).or_default().push(proc.clone());
329    }
330
331    let mut groups: Vec<ProcessGroup> = groups
332        .into_iter()
333        .map(|(name, procs)| {
334            let executable = procs.first().and_then(|p| p.executable.clone());
335            ProcessGroup {
336                name,
337                executable,
338                processes: procs,
339            }
340        })
341        .filter(|group| group.processes.len() > 1) // Only groups with multiple processes
342        .collect();
343    groups.sort_by(|a, b| {
344        b.processes
345            .len()
346            .cmp(&a.processes.len())
347            .then_with(|| a.name.cmp(&b.name))
348    });
349    groups
350}
351
352/// Group processes by session ID (same process group)
353pub fn group_by_session(processes: &[ProcessInfo]) -> Vec<ProcessGroup> {
354    let mut groups: HashMap<u32, Vec<ProcessInfo>> = HashMap::new();
355
356    for proc in processes {
357        if let Some(session) = proc.session {
358            groups.entry(session).or_default().push(proc.clone());
359        }
360    }
361
362    groups
363        .into_iter()
364        .map(|(session_id, procs)| {
365            let name = procs
366                .first()
367                .map(|p| format!("{} (session {})", p.name, session_id))
368                .unwrap_or_else(|| format!("Session {}", session_id));
369            let executable = procs.first().and_then(|p| p.executable.clone());
370            ProcessGroup {
371                name,
372                executable,
373                processes: procs,
374            }
375        })
376        .filter(|group| group.processes.len() > 1)
377        .collect()
378}
379
380/// Find all processes that share the same parent process tree
381/// Returns processes that are descendants of the given PID
382pub fn find_process_tree(root_pid: u32) -> Result<Vec<u32>> {
383    let all_processes = list_all()?;
384    let mut result = vec![root_pid];
385    let mut to_check = vec![root_pid];
386    let mut checked = std::collections::HashSet::new();
387    checked.insert(root_pid);
388
389    while let Some(pid) = to_check.pop() {
390        // Find all processes with this PID as parent
391        for proc in &all_processes {
392            if let Some(ppid) = proc.ppid {
393                if ppid == pid && !checked.contains(&proc.pid) {
394                    result.push(proc.pid);
395                    to_check.push(proc.pid);
396                    checked.insert(proc.pid);
397                }
398            }
399        }
400    }
401
402    Ok(result)
403}
404
405/// Find all processes matching an executable name (all instances)
406pub fn find_all_by_executable(executable_name: &str) -> Result<Vec<ProcessInfo>> {
407    let all = list_all()?;
408    let mut matches = Vec::new();
409
410    for proc in all {
411        let matches_name = proc.name == executable_name
412            || proc
413                .executable
414                .as_ref()
415                .and_then(|exe| exe.file_name())
416                .and_then(|n| n.to_str())
417                .map(|n| n == executable_name)
418                .unwrap_or(false);
419
420        if matches_name {
421            matches.push(proc);
422        }
423    }
424
425    if matches.is_empty() {
426        return Err(Error::ProcessNameNotFound(executable_name.to_string()));
427    }
428
429    Ok(matches)
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn parse_status_reads_uid_name_and_rss_plus_swap() {
438        let s = "Name:\tIsolated Web Co\nUid:\t1000\t1000\t1000\t1000\nVmRSS:\t  500000 kB\nVmSwap:\t   2000 kB\n";
439        assert_eq!(
440            parse_status(s),
441            Some(StatusFields {
442                uid: 1000,
443                name: "Isolated Web Co".into(),
444                rss_kb: 502_000
445            })
446        );
447    }
448
449    #[test]
450    fn parse_status_requires_uid_and_name() {
451        assert_eq!(parse_status("VmRSS:\t1 kB\n"), None);
452        assert_eq!(parse_status("Name:\tx\n"), None);
453    }
454
455    #[test]
456    fn parse_cgroup_v2_skips_hybrid_lines() {
457        assert_eq!(
458            parse_cgroup_v2("1:name=systemd:/foo\n0::/bar\n"),
459            Some("/bar".into())
460        );
461        assert_eq!(parse_cgroup_v2(""), None);
462    }
463
464    #[test]
465    fn list_for_uid_contains_self_and_only_that_uid() {
466        let uid = current_uid();
467        let procs = list_for_uid(uid).unwrap();
468        let me = procs
469            .iter()
470            .find(|p| p.pid == std::process::id())
471            .expect("own process listed");
472        assert!(me.cgroup.is_some(), "cgroup path read");
473        assert!(me.rss_kb > 0, "rss read");
474        assert!(procs.iter().all(|p| p.uid == uid));
475    }
476
477    #[test]
478    fn display_name_prefers_exe_basename() {
479        let p = ProcessInfo {
480            name: "Isolated Web Co".into(),
481            executable: Some(PathBuf::from("/usr/lib/firefox/firefox")),
482            ..Default::default()
483        };
484        assert_eq!(p.display_name(), "firefox");
485        let q = ProcessInfo {
486            name: "kworker".into(),
487            ..Default::default()
488        };
489        assert_eq!(q.display_name(), "kworker");
490    }
491
492    #[test]
493    fn exe_name_ignores_deleted_suffix() {
494        let p = ProcessInfo {
495            executable: Some(PathBuf::from("/opt/google/chrome/chrome (deleted)")),
496            ..Default::default()
497        };
498        assert_eq!(p.exe_name(), Some("chrome"));
499    }
500
501    #[test]
502    fn groups_are_ordered_by_size_then_name() {
503        let p = |pid: u32, exe: &str| ProcessInfo {
504            pid,
505            name: exe.into(),
506            executable: Some(format!("/bin/{exe}").into()),
507            ..Default::default()
508        };
509        let procs = vec![
510            p(1, "b"),
511            p(2, "b"),
512            p(3, "a"),
513            p(4, "a"),
514            p(5, "c"),
515            p(6, "c"),
516            p(7, "c"),
517        ];
518        let names: Vec<String> = group_by_executable(&procs)
519            .into_iter()
520            .map(|g| g.name)
521            .collect();
522        assert_eq!(names, vec!["c", "a", "b"]);
523    }
524}