1use common::{Error, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Clone, Debug, Default, PartialEq, Eq)]
8pub struct ProcessInfo {
9 pub pid: u32,
10 pub name: String,
12 pub ppid: Option<u32>,
13 pub session: Option<u32>,
14 pub executable: Option<PathBuf>,
15 pub uid: u32,
17 pub rss_kb: u64,
19 pub cgroup: Option<String>,
21}
22
23impl ProcessInfo {
24 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 pub fn display_name(&self) -> &str {
35 self.exe_name().unwrap_or(&self.name)
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct StatusFields {
42 pub uid: u32,
43 pub name: String,
44 pub rss_kb: u64,
45}
46
47pub 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 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
82fn first_kb(rest: &str) -> Option<u64> {
84 rest.split_whitespace().next()?.parse().ok()
85}
86
87pub 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
95pub fn current_uid() -> u32 {
97 unsafe { libc::getuid() }
99}
100
101pub struct ProcessGroup {
103 pub name: String,
104 pub executable: Option<PathBuf>,
105 pub processes: Vec<ProcessInfo>,
106}
107
108fn read_process_stat(proc_path: &Path) -> Option<(u32, u32)> {
110 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
123fn get_executable(proc_path: &Path) -> Option<PathBuf> {
125 fs::read_link(proc_path.join("exe")).ok()
126}
127
128pub 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
158pub 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
180pub 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 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
207pub 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct NameMatches {
269 pub pids: Vec<u32>,
270 pub other_users: usize,
271}
272
273pub 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
315pub 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) .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
352pub 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
380pub 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 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
405pub 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}