Skip to main content

proc_tree/
proc.rs

1//! Raw /proc reading for process tree construction.
2//!
3//! These are internal functions used by the process tree operations.
4//! They are not part of the public API.
5//!
6//! Contains functions needed to build and maintain the process tree:
7//! comm (cmd name), status (ppid/user/tgid), stat (start_time), uid lookup.
8
9use std::collections::HashMap;
10use std::sync::OnceLock;
11
12use arrayvec::ArrayString;
13
14const UNKNOWN: &str = "unknown";
15
16/// Clock ticks per second (POSIX `sysconf(_SC_CLK_TCK)`).
17///
18/// Returns 100 as fallback — the overwhelmingly common value on Linux.
19/// Cached after the first call since the value never changes at runtime.
20fn clock_ticks_per_sec() -> i64 {
21    static TICKS: OnceLock<i64> = OnceLock::new();
22    *TICKS.get_or_init(|| {
23        // SAFETY: sysconf(_SC_CLK_TCK) is a pure read-only query with no
24        // side effects, cannot fail or cause UB. It returns a system-wide
25        // constant that is set at boot and never changes.
26        let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
27        if ticks <= 0 { 100 } else { ticks }
28    })
29}
30
31/// Read the command name for a PID from `/proc/{pid}/comm`.
32///
33/// Returns `None` if the process doesn't exist or the file can't be read.
34///
35/// # Internal Usage
36///
37/// This function is used internally by `ops::resolve` and
38/// `ops::get_cmd` as a fallback when the command name is not
39/// in the store.
40pub fn read_proc_comm(pid: u32) -> Option<String> {
41    let path = proc_path(pid, "comm");
42    let mut buf = [0u8; 64];
43    let mut file = std::fs::File::open(path.as_str()).ok()?;
44    use std::io::Read;
45    let n = file.read(&mut buf).ok()?;
46    let s = std::str::from_utf8(&buf[..n]).ok()?;
47    Some(s.trim().to_string())
48}
49
50/// Format `/proc/{pid}/{suffix}` into a stack-allocated string.
51fn proc_path(pid: u32, suffix: &str) -> ArrayString<32> {
52    use std::fmt::Write;
53    let mut buf = ArrayString::new();
54    write!(buf, "/proc/{pid}/{suffix}").unwrap();
55    buf
56}
57
58/// Read the process start time in nanoseconds from `/proc/{pid}/stat`.
59///
60/// Returns 0 if the process doesn't exist or parsing fails.
61/// The value is jiffies-since-boot converted to nanoseconds.
62///
63/// # Internal Usage
64///
65/// This function is used internally by [`parse_proc_entry`] to populate
66/// the `start_time_ns` field of [`super::types::ProcessInfo`].
67pub fn read_proc_start_time_ns(pid: u32) -> u64 {
68    let path = proc_path(pid, "stat");
69    let stat = match std::fs::read_to_string(path.as_str()) {
70        Ok(s) => s,
71        Err(_) => return 0,
72    };
73    // Skip comm field (which may contain spaces and ')') by finding the
74    // last ')' followed by a space — this is the standard Linux convention.
75    let after_comm = match stat.rfind(") ") {
76        Some(pos) => pos + 2,
77        None => return 0,
78    };
79    let mut rest = &stat[after_comm..];
80    // Fields after comm: state, ppid, pgrp, session, tty_nr, tpgid,
81    // flags, minflt, cminflt, majflt, cmajflt, utime, stime, cutime,
82    // cstime, priority, nice, num_threads, itrealvalue, starttime
83    // That's 19 fields to skip (indices 3..22, 0-indexed from after comm).
84    for _ in 0..19 {
85        if let Some(pos) = rest.find(' ') {
86            rest = &rest[pos + 1..];
87        } else {
88            return 0;
89        }
90    }
91    let starttime_jiffies: u64 = match rest.split_whitespace().next() {
92        Some(s) => s.parse().unwrap_or(0),
93        None => return 0,
94    };
95    if starttime_jiffies == 0 {
96        return 0;
97    }
98    (starttime_jiffies as u128 * 1_000_000_000 / clock_ticks_per_sec() as u128) as u64
99}
100
101// ---- UID → username lookup ----
102
103fn uid_passwd_map() -> &'static HashMap<u32, String> {
104    static MAP: OnceLock<HashMap<u32, String>> = OnceLock::new();
105    MAP.get_or_init(|| {
106        let mut map = HashMap::new();
107        if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
108            for entry in passwd.lines() {
109                let mut parts = entry.splitn(4, ':');
110                let name = parts.next();
111                let _password = parts.next();
112                let uid_str = parts.next();
113                if let (Some(name), Some(uid_str)) = (name, uid_str)
114                    && let Ok(uid) = uid_str.parse::<u32>()
115                {
116                    map.insert(uid, name.to_string());
117                }
118            }
119        }
120        map
121    })
122}
123
124/// Read the full command line for a PID from `/proc/{pid}/cmdline`.
125///
126/// Returns `None` if the process doesn't exist or the file can't be read.
127/// The null-byte separators between arguments are replaced with spaces.
128/// Kernel threads (which have empty cmdline) return `None`.
129pub fn read_proc_cmdline(pid: u32) -> Option<String> {
130    let path = proc_path(pid, "cmdline");
131    let bytes = std::fs::read(path.as_str()).ok()?;
132    if bytes.is_empty() {
133        return None;
134    }
135    // cmdline uses NUL bytes between arguments; trim trailing NUL and join with spaces.
136    let s = String::from_utf8_lossy(&bytes);
137    let trimmed = s.trim_end_matches('\0');
138    Some(trimmed.replace('\0', " "))
139}
140
141/// Parse `/proc/{pid}/status`, `/proc/{pid}/comm`, and `/proc/{pid}/cmdline` into a `ProcessInfo`.
142///
143/// Returns `None` if the process doesn't exist or the status file can't be read.
144///
145/// # Internal Usage
146///
147/// This function is used internally by:
148/// - [`super::ops::snapshot`] to populate the store
149/// - [`super::ops::resolve`] as a fallback
150/// - [`super::ops::handle_event`] for Exec events
151/// - [`super::ops::build_chain_links`] for chain lookups
152/// - [`super::ops::find_by_cmd`] and [`super::ops::find_by_user`] for fallback
153pub fn parse_proc_entry(pid: u32) -> Option<crate::types::ProcessInfo> {
154    let path = proc_path(pid, "status");
155    let status = std::fs::read_to_string(path.as_str()).ok()?;
156    let mut ppid = 0u32;
157    let mut user = String::new();
158    let mut tgid = 0u32;
159    for line in status.lines() {
160        if let Some(val) = line.strip_prefix("PPid:") {
161            ppid = val.trim().parse().unwrap_or(0);
162        } else if let Some(val) = line.strip_prefix("Uid:") {
163            if let Some(uid_str) = val.split_whitespace().next()
164                && let Ok(uid) = uid_str.parse::<u32>()
165            {
166                user = uid_to_username(uid).unwrap_or(UNKNOWN).to_owned();
167            } else {
168                user = UNKNOWN.to_string();
169            }
170        } else if let Some(val) = line.strip_prefix("Tgid:") {
171            tgid = val.trim().parse().unwrap_or(0);
172        }
173    }
174    // comm: binary name from /proc/{pid}/comm (truncated to 15 chars by kernel).
175    // Used for process tree matching.
176    let comm = read_proc_comm(pid).unwrap_or_else(|| UNKNOWN.to_string());
177    // cmd: full command line from /proc/{pid}/cmdline.
178    // Falls back to comm for kernel threads where cmdline is empty.
179    let cmd = read_proc_cmdline(pid).unwrap_or_else(|| comm.clone());
180    let start_time_ns = read_proc_start_time_ns(pid);
181    Some(crate::types::ProcessInfo::new(
182        comm,
183        cmd,
184        user,
185        ppid,
186        tgid,
187        start_time_ns,
188    ))
189}
190
191/// Convert a UID to a username by looking up `/etc/passwd`.
192///
193/// Results are cached after the first call. Returns `None` if the UID
194/// is not found in `/etc/passwd`.
195///
196/// # Internal Usage
197///
198/// This function is used internally by [`parse_proc_entry`] to populate
199/// the `user` field of [`super::types::ProcessInfo`].
200pub fn uid_to_username(uid: u32) -> Option<&'static str> {
201    uid_passwd_map().get(&uid).map(|s| s.as_str())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_read_proc_comm_pid1() {
210        let comm = read_proc_comm(1);
211        assert!(comm.is_some(), "PID 1 should exist");
212        assert!(!comm.unwrap().is_empty());
213    }
214
215    #[test]
216    fn test_read_proc_comm_nonexistent() {
217        assert!(read_proc_comm(0x7FFFFFFF).is_none());
218    }
219
220    #[test]
221    fn test_read_proc_start_time_ns_pid1() {
222        let ns = read_proc_start_time_ns(1);
223        assert!(ns > 0, "PID 1 start_time_ns should be > 0, got {ns}");
224    }
225
226    #[test]
227    fn test_read_proc_start_time_ns_nonexistent() {
228        assert_eq!(read_proc_start_time_ns(0x7FFFFFFF), 0);
229    }
230
231    #[test]
232    fn test_uid_to_username_root() {
233        // root (UID 0) should always exist on Linux
234        let name = uid_to_username(0);
235        assert_eq!(name, Some("root"));
236    }
237
238    #[test]
239    fn test_uid_to_username_nonexistent() {
240        // UID 0xFFFFFFFF almost certainly doesn't exist
241        assert!(uid_to_username(0xFFFFFFFF).is_none());
242    }
243
244    #[test]
245    fn test_read_proc_cmdline_pid1() {
246        let cmdline = read_proc_cmdline(1);
247        assert!(cmdline.is_some(), "PID 1 should have a cmdline");
248        let s = cmdline.unwrap();
249        assert!(!s.is_empty());
250        // PID 1 is typically "init" or "systemd" — should not contain NUL bytes
251        assert!(
252            !s.contains('\0'),
253            "cmdline should not contain NUL bytes: {:?}",
254            s
255        );
256    }
257
258    #[test]
259    fn test_read_proc_cmdline_nonexistent() {
260        assert!(read_proc_cmdline(0x7FFFFFFF).is_none());
261    }
262
263    #[test]
264    fn test_read_proc_cmdline_kernel_thread() {
265        // PID 2 (kthreadd) has empty cmdline on Linux
266        let cmdline = read_proc_cmdline(2);
267        // Should be None for kernel threads
268        assert!(
269            cmdline.is_none(),
270            "kernel thread PID 2 should have empty cmdline"
271        );
272    }
273
274    #[test]
275    fn test_parse_proc_entry_uses_full_cmdline() {
276        // PID 1 should have a full cmdline (e.g. "/sbin/init" or "/usr/lib/systemd/systemd")
277        let info = parse_proc_entry(1).expect("PID 1 should exist");
278        // The cmd should NOT be just "init" or "systemd" (the Name: field),
279        // but the full path from cmdline
280        assert!(
281            info.cmd().len() > 15 || !info.cmd().contains(' '),
282            "PID 1 cmd should be full cmdline, got: {:?}",
283            info.cmd()
284        );
285    }
286}