use common::{Error, Result};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProcessInfo {
pub pid: u32,
pub name: String,
pub ppid: Option<u32>,
pub session: Option<u32>,
pub executable: Option<PathBuf>,
pub uid: u32,
pub rss_kb: u64,
pub cgroup: Option<String>,
}
impl ProcessInfo {
pub fn exe_name(&self) -> Option<&str> {
let name = self.executable.as_deref()?.file_name()?.to_str()?;
Some(name.strip_suffix(" (deleted)").unwrap_or(name))
}
pub fn display_name(&self) -> &str {
self.exe_name().unwrap_or(&self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusFields {
pub uid: u32,
pub name: String,
pub rss_kb: u64,
}
pub fn parse_status(status: &str) -> Option<StatusFields> {
let mut uid = None;
let mut name = None;
let mut vm_rss = 0u64;
let mut vm_swap = 0u64;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("Name:") {
name = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix("Uid:") {
uid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
} else if let Some(rest) = line.strip_prefix("VmRSS:") {
vm_rss = first_kb(rest).unwrap_or(0);
} else if let Some(rest) = line.strip_prefix("VmSwap:") {
vm_swap = first_kb(rest).unwrap_or(0);
}
}
Some(StatusFields {
uid: uid?,
name: name?,
rss_kb: vm_rss.saturating_add(vm_swap),
})
}
fn first_kb(rest: &str) -> Option<u64> {
rest.split_whitespace().next()?.parse().ok()
}
pub fn parse_cgroup_v2(content: &str) -> Option<String> {
content
.lines()
.find_map(|l| l.strip_prefix("0::").map(|p| p.to_string()))
}
pub fn current_uid() -> u32 {
unsafe { libc::getuid() }
}
pub struct ProcessGroup {
pub name: String,
pub executable: Option<PathBuf>,
pub processes: Vec<ProcessInfo>,
}
impl ProcessGroup {
pub fn rss_kb(&self) -> u64 {
self.processes.iter().map(|p| p.rss_kb).sum()
}
}
fn read_process_stat(proc_path: &Path) -> Option<(u32, u32)> {
parse_ppid_session(&fs::read_to_string(proc_path.join("stat")).ok()?)
}
fn parse_ppid_session(stat: &str) -> Option<(u32, u32)> {
let mut fields = stat[stat.rfind(')')? + 1..].split_whitespace();
let ppid = fields.nth(1)?.parse().ok()?;
let session = fields.nth(1)?.parse().ok()?;
Some((ppid, session))
}
pub fn parse_start_time(stat: &str) -> Option<u64> {
let rest = &stat[stat.rfind(')')? + 1..];
rest.split_whitespace().nth(19)?.parse().ok()
}
pub fn start_time(pid: u32) -> Option<u64> {
parse_start_time(&fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)
}
fn get_executable(proc_path: &Path) -> Option<PathBuf> {
fs::read_link(proc_path.join("exe")).ok()
}
pub fn read_process(pid: u32) -> Option<ProcessInfo> {
let proc_path = Path::new("/proc").join(pid.to_string());
let status = fs::read_to_string(proc_path.join("status")).ok()?;
let fields = parse_status(&status)?;
let (ppid, session) = read_process_stat(&proc_path).unwrap_or((0, 0));
let executable = get_executable(&proc_path);
let cgroup = fs::read_to_string(proc_path.join("cgroup"))
.ok()
.and_then(|c| parse_cgroup_v2(&c));
Some(ProcessInfo {
pid,
name: fields.name,
ppid: if ppid > 0 { Some(ppid) } else { None },
session: if session > 0 { Some(session) } else { None },
executable,
uid: fields.uid,
rss_kb: fields.rss_kb,
cgroup,
})
}
pub fn list_all() -> Result<Vec<ProcessInfo>> {
let mut processes = Vec::new();
for entry in fs::read_dir("/proc")?.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|n| n.parse::<u32>().ok())
else {
continue;
};
if let Some(p) = read_process(pid) {
processes.push(p);
}
}
processes.sort_by(|a, b| a.name.cmp(&b.name));
Ok(processes)
}
pub fn list_for_uid(uid: u32) -> Result<Vec<ProcessInfo>> {
use std::os::unix::fs::MetadataExt;
let mut out = Vec::new();
for entry in fs::read_dir("/proc")?.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|n| n.parse::<u32>().ok())
else {
continue;
};
if entry.metadata().ok().map(|m| m.uid()) != Some(uid) {
continue;
}
if let Some(p) = read_process(pid) {
if p.uid == uid {
out.push(p);
}
}
}
Ok(out)
}
pub fn find_by_name(name: &str) -> Result<Vec<u32>> {
let mut pids = Vec::new();
for entry in fs::read_dir("/proc")? {
let entry = entry?;
let path = entry.path();
let Some(pid_str) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
if matches_name(&path, name) {
pids.push(pid);
}
}
if pids.is_empty() {
return Err(Error::ProcessNameNotFound(name.to_string()));
}
Ok(pids)
}
fn matches_name(proc_path: &Path, name: &str) -> bool {
if let Ok(comm) = fs::read_to_string(proc_path.join("comm")) {
let comm = comm.trim();
if comm == name {
return true;
}
if comm.len() == 15 && name.len() > 15 && name.starts_with(comm) {
if let Ok(exe) = fs::read_link(proc_path.join("exe")) {
if let Some(exe_name) = exe.file_name().and_then(|n| n.to_str()) {
return exe_name == name;
}
}
}
}
if let Ok(exe) = fs::read_link(proc_path.join("exe")) {
if let Some(exe_name) = exe.file_name().and_then(|n| n.to_str()) {
if exe_name == name {
return true;
}
}
}
false
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NameMatches {
pub pids: Vec<u32>,
pub other_users: usize,
}
pub fn find_by_name_for_uid(name: &str, uid: u32) -> Result<NameMatches> {
use std::os::unix::fs::MetadataExt;
let mut pids = Vec::new();
let mut other_users = 0usize;
for entry in fs::read_dir("/proc")?.flatten() {
let path = entry.path();
let Some(pid) = path
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.parse::<u32>().ok())
else {
continue;
};
if !matches_name(&path, name) {
continue;
}
let Ok(owner_uid) = entry.metadata().map(|m| m.uid()) else {
continue;
};
if owner_uid == uid {
pids.push(pid);
} else {
other_users += 1;
}
}
if pids.is_empty() && other_users == 0 {
return Err(Error::ProcessNameNotFound(name.to_string()));
}
Ok(NameMatches { pids, other_users })
}
pub fn group_by_executable(processes: &[ProcessInfo]) -> Vec<ProcessGroup> {
let mut groups: HashMap<String, Vec<ProcessInfo>> = HashMap::new();
for proc in processes {
let key = proc
.executable
.as_ref()
.and_then(|exe| exe.file_name())
.and_then(|n| n.to_str())
.map(String::from)
.unwrap_or_else(|| proc.name.clone());
groups.entry(key).or_default().push(proc.clone());
}
let mut groups: Vec<ProcessGroup> = groups
.into_iter()
.map(|(name, procs)| {
let executable = procs.first().and_then(|p| p.executable.clone());
ProcessGroup {
name,
executable,
processes: procs,
}
})
.collect();
groups.sort_by(|a, b| {
b.rss_kb()
.cmp(&a.rss_kb())
.then_with(|| a.name.cmp(&b.name))
});
groups
}
pub fn group_by_session(processes: &[ProcessInfo]) -> Vec<ProcessGroup> {
let mut groups: HashMap<u32, Vec<ProcessInfo>> = HashMap::new();
for proc in processes {
if let Some(session) = proc.session {
groups.entry(session).or_default().push(proc.clone());
}
}
groups
.into_iter()
.map(|(session_id, procs)| {
let name = procs
.first()
.map(|p| format!("{} (session {})", p.name, session_id))
.unwrap_or_else(|| format!("Session {}", session_id));
let executable = procs.first().and_then(|p| p.executable.clone());
ProcessGroup {
name,
executable,
processes: procs,
}
})
.filter(|group| group.processes.len() > 1)
.collect()
}
pub fn find_process_tree(root_pid: u32) -> Result<Vec<u32>> {
let all_processes = list_all()?;
let mut result = vec![root_pid];
let mut to_check = vec![root_pid];
let mut checked = std::collections::HashSet::new();
checked.insert(root_pid);
while let Some(pid) = to_check.pop() {
for proc in &all_processes {
if let Some(ppid) = proc.ppid {
if ppid == pid && !checked.contains(&proc.pid) {
result.push(proc.pid);
to_check.push(proc.pid);
checked.insert(proc.pid);
}
}
}
}
Ok(result)
}
pub fn find_all_by_executable(executable_name: &str) -> Result<Vec<ProcessInfo>> {
let all = list_all()?;
let mut matches = Vec::new();
for proc in all {
let matches_name = proc.name == executable_name
|| proc
.executable
.as_ref()
.and_then(|exe| exe.file_name())
.and_then(|n| n.to_str())
.map(|n| n == executable_name)
.unwrap_or(false);
if matches_name {
matches.push(proc);
}
}
if matches.is_empty() {
return Err(Error::ProcessNameNotFound(executable_name.to_string()));
}
Ok(matches)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn start_time_is_field_22_counted_after_the_comm() {
let tail = "R 2238197 2238197 2238197 0 -1 4194304 519 0 0 0 0 0 0 0 20 0 1 0 5852809 18214912 1841";
assert_eq!(
parse_start_time(&format!("2238200 (cat) {tail}")),
Some(5852809)
);
assert_eq!(
parse_start_time(&format!("42 (Web Co) (x)) {tail}")),
Some(5852809)
);
assert_eq!(parse_start_time("42 (cat) R 1 2"), None);
assert_eq!(parse_start_time(""), None);
assert!(start_time(std::process::id()).is_some());
}
#[test]
fn parse_status_reads_uid_name_and_rss_plus_swap() {
let s = "Name:\tIsolated Web Co\nUid:\t1000\t1000\t1000\t1000\nVmRSS:\t 500000 kB\nVmSwap:\t 2000 kB\n";
assert_eq!(
parse_status(s),
Some(StatusFields {
uid: 1000,
name: "Isolated Web Co".into(),
rss_kb: 502_000
})
);
}
#[test]
fn parse_status_requires_uid_and_name() {
assert_eq!(parse_status("VmRSS:\t1 kB\n"), None);
assert_eq!(parse_status("Name:\tx\n"), None);
}
#[test]
fn parse_cgroup_v2_skips_hybrid_lines() {
assert_eq!(
parse_cgroup_v2("1:name=systemd:/foo\n0::/bar\n"),
Some("/bar".into())
);
assert_eq!(parse_cgroup_v2(""), None);
}
#[test]
fn list_for_uid_contains_self_and_only_that_uid() {
let uid = current_uid();
let procs = list_for_uid(uid).unwrap();
let me = procs
.iter()
.find(|p| p.pid == std::process::id())
.expect("own process listed");
assert!(me.cgroup.is_some(), "cgroup path read");
assert!(me.rss_kb > 0, "rss read");
assert!(procs.iter().all(|p| p.uid == uid));
}
#[test]
fn display_name_prefers_exe_basename() {
let p = ProcessInfo {
name: "Isolated Web Co".into(),
executable: Some(PathBuf::from("/usr/lib/firefox/firefox")),
..Default::default()
};
assert_eq!(p.display_name(), "firefox");
let q = ProcessInfo {
name: "kworker".into(),
..Default::default()
};
assert_eq!(q.display_name(), "kworker");
}
#[test]
fn exe_name_ignores_deleted_suffix() {
let p = ProcessInfo {
executable: Some(PathBuf::from("/opt/google/chrome/chrome (deleted)")),
..Default::default()
};
assert_eq!(p.exe_name(), Some("chrome"));
}
#[test]
fn groups_are_ordered_by_memory_then_name() {
let p = |pid: u32, exe: &str, rss_kb: u64| ProcessInfo {
pid,
name: exe.into(),
executable: Some(format!("/bin/{exe}").into()),
rss_kb,
..Default::default()
};
let procs = vec![
p(1, "b", 100),
p(2, "b", 100),
p(3, "a", 150),
p(4, "a", 50),
p(5, "c", 10),
p(6, "c", 10),
p(7, "c", 10),
p(8, "d", 900),
];
let groups = group_by_executable(&procs);
let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, vec!["d", "a", "b", "c"]);
assert_eq!(groups[0].rss_kb(), 900);
assert_eq!(groups[1].rss_kb(), 200);
}
#[test]
fn ppid_and_session_survive_a_comm_with_spaces() {
let stat = "4242 (Isolated Web Co) S 4100 4100 3000 0 -1 4194560";
assert_eq!(parse_ppid_session(stat), Some((4100, 3000)));
assert_eq!(parse_ppid_session("7 (a) b) R 1 7 7 0"), Some((1, 7)));
assert_eq!(parse_ppid_session("garbage"), None);
}
}