Skip to main content

rightkit_child_process/
process_table.rs

1//! A cross-platform snapshot of the process table: pid, parent pid, command line and working set.
2//!
3//! It replaces callers that shell out to `ps` or to PowerShell `Get-CimInstance Win32_Process`,
4//! which is slow, spawns a console host on Windows and parses text that differs between hosts.
5//! Nothing is kept between calls: each call builds a fresh `Vec` and releases every handle.
6//!
7//! * Linux: `/proc/<pid>/stat` (parent), `/proc/<pid>/statm` (resident pages) and
8//!   `/proc/<pid>/cmdline` (arguments joined by one space).
9//! * macOS: `proc_listpids`, `proc_pidinfo` (`PROC_PIDTBSDINFO`, `PROC_PIDTASKINFO`) and the
10//!   `KERN_PROCARGS2` sysctl.
11//! * Windows: a Toolhelp snapshot (pid, parent), `GetProcessMemoryInfo` (working set) and
12//!   `NtQueryInformationProcess(ProcessCommandLineInformation)` (command line, Windows 8.1+).
13//!
14//! The table is a racy snapshot: a process can exit, or its pid be reused, while it is read.
15//! Use `AdoptedIdentity` before signalling anything found here. A field the caller may not read
16//! (another user's, protected or exiting process, a kernel thread) is `None`, never a guess, and
17//! one unreadable process never fails the scan. A process whose parent cannot be read on Linux or
18//! macOS (it exited between the listing and the read) is left out; on Windows the parent comes
19//! from the snapshot itself, so every listed process has one. Other hosts return
20//! `ErrorKind::Unsupported`.
21
22use std::io;
23
24/// One row of the process table.
25#[derive(Clone, Debug, Eq, PartialEq)]
26#[non_exhaustive]
27pub struct ProcessEntry {
28    /// The process id.
29    pub pid: u32,
30    /// The parent process id as the OS reports it (0 when it has none).
31    pub parent_pid: u32,
32    /// The full command line as one string. Linux and macOS join the arguments with a single
33    /// space (the original quoting is not recoverable); Windows returns the raw command line.
34    /// `None` when it cannot be read or is empty.
35    pub command_line: Option<String>,
36    /// Resident (working set) memory in bytes. `None` when it cannot be read.
37    pub working_set_bytes: Option<u64>,
38}
39
40/// Snapshot every process the OS lets this process see.
41///
42/// Returns `ErrorKind::Unsupported` on hosts other than Linux, macOS and Windows.
43pub fn process_table() -> io::Result<Vec<ProcessEntry>> {
44    imp::table()
45}
46
47/// Parse `/proc/<pid>/stat` and return the parent pid. The command name (field 2) may contain
48/// spaces and parentheses, so fields are counted after the last `)`.
49#[cfg(any(target_os = "linux", target_os = "android", test))]
50fn parse_stat_parent(stat: &str) -> Option<u32> {
51    let rest = &stat[stat.rfind(')')? + 1..];
52    // After the name: state, ppid, ...
53    rest.split_whitespace().nth(1)?.parse().ok()
54}
55
56/// Join NUL-separated `/proc/<pid>/cmdline` bytes into one space-separated string.
57#[cfg(any(target_os = "linux", target_os = "android", test))]
58fn join_cmdline(bytes: &[u8]) -> Option<String> {
59    let trimmed = bytes.strip_suffix(&[0]).unwrap_or(bytes);
60    if trimmed.is_empty() {
61        return None;
62    }
63    let text = String::from_utf8_lossy(trimmed);
64    let joined = text.split('\0').collect::<Vec<_>>().join(" ");
65    (!joined.trim().is_empty()).then_some(joined)
66}
67
68/// Join a `KERN_PROCARGS2` buffer (argc, executable path, padding NULs, then argv) into one
69/// space-separated string of the arguments, without the environment.
70#[cfg(any(target_os = "macos", test))]
71fn join_procargs2(buffer: &[u8]) -> Option<String> {
72    let count = usize::try_from(i32::from_ne_bytes(buffer.get(..4)?.try_into().ok()?)).ok()?;
73    let mut i = 4;
74    while i < buffer.len() && buffer[i] != 0 {
75        i += 1;
76    }
77    while i < buffer.len() && buffer[i] == 0 {
78        i += 1;
79    }
80    let mut args = Vec::with_capacity(count);
81    for _ in 0..count {
82        if i >= buffer.len() {
83            return None;
84        }
85        let start = i;
86        while i < buffer.len() && buffer[i] != 0 {
87            i += 1;
88        }
89        if i >= buffer.len() {
90            return None; // cut short: the last argument has no terminator
91        }
92        args.push(String::from_utf8_lossy(&buffer[start..i]).into_owned());
93        i += 1;
94    }
95    let joined = args.join(" ");
96    (!joined.is_empty()).then_some(joined)
97}
98
99#[cfg(any(target_os = "linux", target_os = "android"))]
100mod imp {
101    use super::{join_cmdline, parse_stat_parent, ProcessEntry};
102    use std::io;
103
104    pub(super) fn table() -> io::Result<Vec<ProcessEntry>> {
105        // SAFETY: sysconf takes a plain name and has no memory effects.
106        let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(1) as u64;
107        let mut rows = Vec::new();
108        for entry in std::fs::read_dir("/proc")?.flatten() {
109            let Some(pid) = entry
110                .file_name()
111                .to_str()
112                .and_then(|name| name.parse::<u32>().ok())
113            else {
114                continue;
115            };
116            // A process that exited between the listing and this read is skipped.
117            // Bytes, not a string: the command name in `stat` is not always valid UTF-8.
118            let Some(parent_pid) = std::fs::read(format!("/proc/{pid}/stat"))
119                .ok()
120                .and_then(|stat| parse_stat_parent(&String::from_utf8_lossy(&stat)))
121            else {
122                continue;
123            };
124            let working_set_bytes = std::fs::read_to_string(format!("/proc/{pid}/statm"))
125                .ok()
126                .and_then(|statm| statm.split_whitespace().nth(1)?.parse::<u64>().ok())
127                .map(|pages| pages.saturating_mul(page));
128            let command_line = std::fs::read(format!("/proc/{pid}/cmdline"))
129                .ok()
130                .and_then(|bytes| join_cmdline(&bytes));
131            rows.push(ProcessEntry {
132                pid,
133                parent_pid,
134                command_line,
135                working_set_bytes,
136            });
137        }
138        Ok(rows)
139    }
140}
141
142#[cfg(target_os = "macos")]
143mod imp {
144    use super::{join_procargs2, ProcessEntry};
145    use std::io;
146
147    pub(super) fn table() -> io::Result<Vec<ProcessEntry>> {
148        /// `PROC_ALL_PIDS` from `<libproc.h>`.
149        const PROC_ALL_PIDS: u32 = 1;
150        let size = std::mem::size_of::<libc::pid_t>();
151        // SAFETY: a null buffer asks only for the size needed.
152        let needed = unsafe { libc::proc_listpids(PROC_ALL_PIDS, 0, std::ptr::null_mut(), 0) };
153        if needed <= 0 {
154            return Err(io::Error::last_os_error());
155        }
156        let mut pids = vec![0 as libc::pid_t; needed as usize / size + 64];
157        // SAFETY: the buffer holds `pids.len()` pids and the byte size passed says so.
158        let written = unsafe {
159            libc::proc_listpids(
160                PROC_ALL_PIDS,
161                0,
162                pids.as_mut_ptr().cast(),
163                (pids.len() * size) as libc::c_int,
164            )
165        };
166        if written <= 0 {
167            return Err(io::Error::last_os_error());
168        }
169        pids.truncate(written as usize / size);
170        let mut rows = Vec::new();
171        for pid in pids.into_iter().filter(|pid| *pid > 0) {
172            let Some(parent_pid) = parent_of(pid) else {
173                continue; // exited, or not readable
174            };
175            rows.push(ProcessEntry {
176                pid: pid as u32,
177                parent_pid,
178                command_line: command_line(pid as u32),
179                working_set_bytes: resident(pid),
180            });
181        }
182        Ok(rows)
183    }
184
185    fn parent_of(pid: libc::pid_t) -> Option<u32> {
186        // SAFETY: an all-zero `proc_bsdinfo` is a valid value (integers and char arrays).
187        let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
188        let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
189        // SAFETY: `info` is a writable buffer of exactly `size` bytes.
190        let written = unsafe {
191            libc::proc_pidinfo(
192                pid,
193                libc::PROC_PIDTBSDINFO,
194                0,
195                (&mut info as *mut libc::proc_bsdinfo).cast(),
196                size,
197            )
198        };
199        (written == size && info.pbi_pid == pid as u32).then_some(info.pbi_ppid)
200    }
201
202    fn resident(pid: libc::pid_t) -> Option<u64> {
203        // SAFETY: an all-zero `proc_taskinfo` is a valid value (plain integers).
204        let mut info: libc::proc_taskinfo = unsafe { std::mem::zeroed() };
205        let size = std::mem::size_of::<libc::proc_taskinfo>() as libc::c_int;
206        // SAFETY: `info` is a writable buffer of exactly `size` bytes.
207        let written = unsafe {
208            libc::proc_pidinfo(
209                pid,
210                libc::PROC_PIDTASKINFO,
211                0,
212                (&mut info as *mut libc::proc_taskinfo).cast(),
213                size,
214            )
215        };
216        (written == size).then_some(info.pti_resident_size)
217    }
218
219    fn command_line(pid: u32) -> Option<String> {
220        let mut name = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int];
221        let mut capacity: usize = 0;
222        // SAFETY: a null buffer asks only for the size needed; `name` has three entries.
223        let probed = unsafe {
224            libc::sysctl(
225                name.as_mut_ptr(),
226                3,
227                std::ptr::null_mut(),
228                &mut capacity,
229                std::ptr::null_mut(),
230                0,
231            )
232        };
233        if probed != 0 {
234            return None;
235        }
236        let mut buffer = vec![0u8; capacity + 4096];
237        let mut length = buffer.len();
238        // SAFETY: the buffer is writable for `length` bytes and `length` says so.
239        let read = unsafe {
240            libc::sysctl(
241                name.as_mut_ptr(),
242                3,
243                buffer.as_mut_ptr().cast(),
244                &mut length,
245                std::ptr::null_mut(),
246                0,
247            )
248        };
249        if read != 0 {
250            return None;
251        }
252        buffer.truncate(length);
253        join_procargs2(&buffer)
254    }
255}
256
257#[cfg(windows)]
258mod imp {
259    use super::ProcessEntry;
260    use crate::process_windows::os_error;
261    use std::{ffi::c_void, io};
262    use windows::Win32::{
263        Foundation::{CloseHandle, HANDLE},
264        System::{
265            Diagnostics::ToolHelp::{
266                CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
267                TH32CS_SNAPPROCESS,
268            },
269            ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS},
270            Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION},
271        },
272    };
273
274    #[link(name = "ntdll")]
275    extern "system" {
276        fn NtQueryInformationProcess(
277            process: *mut c_void,
278            class: u32,
279            information: *mut c_void,
280            length: u32,
281            returned: *mut u32,
282        ) -> i32;
283    }
284
285    /// `ProcessCommandLineInformation`: a `UNICODE_STRING` followed by its text, Windows 8.1+.
286    const PROCESS_COMMAND_LINE_INFORMATION: u32 = 60;
287    const STATUS_INFO_LENGTH_MISMATCH: u32 = 0xC000_0004;
288    const STATUS_BUFFER_OVERFLOW: u32 = 0x8000_0005;
289    const STATUS_BUFFER_TOO_SMALL: u32 = 0xC000_0023;
290
291    #[repr(C)]
292    struct UnicodeString {
293        length: u16,
294        maximum_length: u16,
295        buffer: *const u16,
296    }
297
298    struct Owned(HANDLE);
299    impl Drop for Owned {
300        fn drop(&mut self) {
301            // SAFETY: the handle came from a successful open or snapshot and is closed once.
302            let _ = unsafe { CloseHandle(self.0) };
303        }
304    }
305
306    pub(super) fn table() -> io::Result<Vec<ProcessEntry>> {
307        // SAFETY: a snapshot request takes plain values.
308        let snapshot =
309            Owned(unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.map_err(os_error)?);
310        // SAFETY: PROCESSENTRY32W is plain data; dwSize is set below as the API requires.
311        let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
312        entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
313        // SAFETY: the snapshot is open and `entry` is a valid, sized entry.
314        unsafe { Process32FirstW(snapshot.0, &mut entry) }.map_err(os_error)?;
315        let mut rows = Vec::new();
316        loop {
317            let pid = entry.th32ProcessID;
318            let (command_line, working_set_bytes) = if pid == 0 {
319                (None, None) // the System Idle Process cannot be opened
320            } else {
321                read_process(pid)
322            };
323            rows.push(ProcessEntry {
324                pid,
325                parent_pid: entry.th32ParentProcessID,
326                command_line,
327                working_set_bytes,
328            });
329            // SAFETY: as above.
330            if unsafe { Process32NextW(snapshot.0, &mut entry) }.is_err() {
331                break;
332            }
333        }
334        Ok(rows)
335    }
336
337    /// Command line and working set of `pid`; each is `None` when the process is gone or may not
338    /// be opened with limited query access (protected and other users' processes).
339    fn read_process(pid: u32) -> (Option<String>, Option<u64>) {
340        // SAFETY: OpenProcess takes plain values and returns a new handle.
341        let Ok(handle) = (unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) })
342        else {
343            return (None, None);
344        };
345        let process = Owned(handle);
346        (command_line(&process), working_set(&process))
347    }
348
349    fn working_set(process: &Owned) -> Option<u64> {
350        let mut counters = PROCESS_MEMORY_COUNTERS::default();
351        let size = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
352        counters.cb = size;
353        // SAFETY: `counters` is a valid, sized structure and the handle has query access.
354        unsafe { GetProcessMemoryInfo(process.0, &mut counters, size) }.ok()?;
355        Some(counters.WorkingSetSize as u64)
356    }
357
358    fn command_line(process: &Owned) -> Option<String> {
359        let mut size: usize = 1024;
360        for _ in 0..4 {
361            let mut buffer = vec![0u64; size.div_ceil(8)];
362            let mut returned = 0u32;
363            // SAFETY: the buffer is writable for the length passed; the handle has query access.
364            let status = unsafe {
365                NtQueryInformationProcess(
366                    process.0 .0,
367                    PROCESS_COMMAND_LINE_INFORMATION,
368                    buffer.as_mut_ptr().cast(),
369                    (buffer.len() * 8) as u32,
370                    &mut returned,
371                )
372            } as u32;
373            match status {
374                0 => return read_unicode_string(&buffer),
375                STATUS_INFO_LENGTH_MISMATCH | STATUS_BUFFER_OVERFLOW | STATUS_BUFFER_TOO_SMALL => {
376                    size = (returned as usize).max(size * 2);
377                }
378                _ => return None, // denied, protected or exiting
379            }
380        }
381        None
382    }
383
384    fn read_unicode_string(buffer: &[u64]) -> Option<String> {
385        let base = buffer.as_ptr() as usize;
386        let end = base + buffer.len() * 8;
387        if buffer.len() * 8 < std::mem::size_of::<UnicodeString>() {
388            return None;
389        }
390        // SAFETY: the buffer is aligned for u64 and large enough for the header.
391        let header = unsafe { &*(buffer.as_ptr().cast::<UnicodeString>()) };
392        let text = header.buffer as usize;
393        let bytes = usize::from(header.length);
394        if bytes == 0 {
395            return None;
396        }
397        // The text must lie inside the buffer, after the header, and be 2-byte aligned.
398        if text < base + std::mem::size_of::<UnicodeString>()
399            || !text.is_multiple_of(2)
400            || !bytes.is_multiple_of(2)
401            || text + bytes > end
402        {
403            return None;
404        }
405        // SAFETY: the range was checked to lie inside the buffer and to be 2-byte aligned.
406        let units = unsafe { std::slice::from_raw_parts(header.buffer, bytes / 2) };
407        Some(String::from_utf16_lossy(units))
408    }
409}
410
411#[cfg(not(any(
412    target_os = "linux",
413    target_os = "android",
414    target_os = "macos",
415    windows
416)))]
417mod imp {
418    use super::ProcessEntry;
419    use std::io;
420
421    pub(super) fn table() -> io::Result<Vec<ProcessEntry>> {
422        Err(io::Error::from(io::ErrorKind::Unsupported))
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn stat_parent_survives_spaces_and_parens_in_the_name() {
432        assert_eq!(parse_stat_parent("42 (bash) S 7 42 42 0 -1"), Some(7));
433        assert_eq!(parse_stat_parent("42 (a b) c) R 9 1 1"), Some(9));
434        assert_eq!(parse_stat_parent("garbage"), None);
435        assert_eq!(parse_stat_parent("42 (x) S"), None);
436    }
437
438    #[test]
439    fn cmdline_joins_arguments_and_empty_is_none() {
440        assert_eq!(
441            join_cmdline(b"ls\0-l\0/tmp\0").as_deref(),
442            Some("ls -l /tmp")
443        );
444        assert_eq!(join_cmdline(b"sd-server\0").as_deref(), Some("sd-server"));
445        assert_eq!(join_cmdline(b""), None);
446        assert_eq!(join_cmdline(b"\0"), None);
447    }
448
449    #[test]
450    fn procargs2_yields_arguments_without_environment() {
451        let mut buffer = 2i32.to_ne_bytes().to_vec();
452        buffer.extend_from_slice(b"/bin/tool\0\0\0tool\0--flag\0HOME=/x\0");
453        assert_eq!(join_procargs2(&buffer).as_deref(), Some("tool --flag"));
454        assert_eq!(join_procargs2(&3i32.to_ne_bytes()), None);
455        let mut cut = 1i32.to_ne_bytes().to_vec();
456        cut.extend_from_slice(b"/bin/tool\0tool");
457        assert_eq!(join_procargs2(&cut), None);
458    }
459
460    /// The parent of this test process, read independently of the table under test.
461    #[cfg(unix)]
462    fn own_parent() -> u32 {
463        // SAFETY: getppid takes no arguments and cannot fail.
464        unsafe { libc::getppid() as u32 }
465    }
466
467    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
468    #[test]
469    fn the_table_contains_this_process_with_its_parent_and_memory() {
470        let rows = process_table().expect("process table");
471        let me = rows
472            .iter()
473            .find(|row| row.pid == std::process::id())
474            .expect("own pid is listed");
475        assert_ne!(me.parent_pid, std::process::id());
476        #[cfg(unix)]
477        assert_eq!(me.parent_pid, own_parent());
478        assert!(me.working_set_bytes.unwrap_or(0) > 0);
479        let line = me.command_line.as_deref().expect("own command line");
480        assert!(!line.is_empty());
481        #[cfg(unix)]
482        {
483            let argv: Vec<String> = std::env::args_os()
484                .map(|arg| arg.to_string_lossy().into_owned())
485                .collect();
486            assert_eq!(line, argv.join(" "));
487        }
488        let exe = std::env::current_exe().unwrap();
489        let stem = exe.file_stem().unwrap().to_string_lossy().into_owned();
490        assert!(line.contains(&stem), "{line} should name {stem}");
491    }
492
493    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
494    #[test]
495    fn a_spawned_child_is_listed_under_this_parent_with_its_arguments() {
496        use std::process::{Command, Stdio};
497        // A distinctive argument, so a forked child that has not exec'd yet (still showing this
498        // process's command line) is never mistaken for the finished one.
499        const MARK: &str = "7654";
500        let mut command = if cfg!(windows) {
501            let mut c = Command::new("ping.exe");
502            c.args(["-n", MARK, "127.0.0.1"]);
503            c
504        } else {
505            let mut c = Command::new("sleep");
506            c.arg(MARK);
507            c
508        };
509        let mut child = command
510            .stdin(Stdio::null())
511            .stdout(Stdio::null())
512            .stderr(Stdio::null())
513            .spawn()
514            .expect("spawn");
515        let pid = child.id();
516        let mut found = None;
517        for _ in 0..50 {
518            found = process_table().unwrap().into_iter().find(|row| {
519                row.pid == pid
520                    && row
521                        .command_line
522                        .as_deref()
523                        .is_some_and(|line| line.contains(MARK))
524            });
525            if found.is_some() {
526                break;
527            }
528            std::thread::sleep(std::time::Duration::from_millis(100));
529        }
530        let _ = child.kill();
531        let _ = child.wait();
532        let row = found.expect("child listed with its command line");
533        assert_eq!(row.parent_pid, std::process::id());
534        let line = row.command_line.unwrap();
535        let expected = if cfg!(windows) {
536            format!("-n {MARK} 127.0.0.1")
537        } else {
538            format!("sleep {MARK}")
539        };
540        assert!(line.contains(&expected), "{line} should contain {expected}");
541    }
542}