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
108impl ProcessGroup {
109 pub fn rss_kb(&self) -> u64 {
111 self.processes.iter().map(|p| p.rss_kb).sum()
112 }
113}
114
115fn read_process_stat(proc_path: &Path) -> Option<(u32, u32)> {
117 parse_ppid_session(&fs::read_to_string(proc_path.join("stat")).ok()?)
118}
119
120fn parse_ppid_session(stat: &str) -> Option<(u32, u32)> {
123 let mut fields = stat[stat.rfind(')')? + 1..].split_whitespace();
124 let ppid = fields.nth(1)?.parse().ok()?;
126 let session = fields.nth(1)?.parse().ok()?;
127 Some((ppid, session))
128}
129
130pub fn parse_start_time(stat: &str) -> Option<u64> {
134 let rest = &stat[stat.rfind(')')? + 1..];
135 rest.split_whitespace().nth(19)?.parse().ok()
137}
138
139pub fn start_time(pid: u32) -> Option<u64> {
143 parse_start_time(&fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)
144}
145
146fn get_executable(proc_path: &Path) -> Option<PathBuf> {
148 fs::read_link(proc_path.join("exe")).ok()
149}
150
151pub 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
181pub 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
203pub 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 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
230pub 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct NameMatches {
292 pub pids: Vec<u32>,
293 pub other_users: usize,
294}
295
296pub 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
338pub 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 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
375pub 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
403pub 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 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
428pub 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 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 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}