Skip to main content

agentsight_capture/sources/
proc.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::io;
6use std::path::PathBuf;
7use std::sync::OnceLock;
8use std::time::Instant;
9use sysinfo::{Pid, Process, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
10
11#[cfg(target_os = "linux")]
12use std::fs;
13
14pub use agent_session::{ProcessKey, ProcessTree};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct PidSeed {
18    pub pid: u32,
19    pub ppid: u32,
20}
21
22impl PidSeed {
23    pub fn arg_value(self) -> String {
24        format!("{}:{}", self.pid, self.ppid)
25    }
26}
27
28#[derive(Debug, Clone, Default)]
29pub struct ProcInfo {
30    pub pid: u32,
31    pub ppid: u32,
32    pub session_id: u32,
33    pub comm: String,
34    pub command: String,
35    pub cwd: Option<PathBuf>,
36    pub ticks: u64,
37    pub starttime_ticks: u64,
38    pub rss_kb: u64,
39    pub rss_mb: u64,
40    pub vsz_kb: u64,
41    pub threads: u32,
42    pub read_bytes: u64,
43    pub write_bytes: u64,
44}
45
46impl ProcInfo {
47    pub fn seed(&self) -> PidSeed {
48        PidSeed {
49            pid: self.pid,
50            ppid: self.ppid,
51        }
52    }
53
54    pub fn process_key(&self) -> ProcessKey {
55        ProcessKey {
56            pid: self.pid,
57            starttime_ticks: self.starttime_ticks,
58        }
59    }
60}
61
62#[derive(Debug, Clone)]
63pub struct ProcSnapshot {
64    pub at: Instant,
65    pub uptime_s: f64,
66    pub procs: BTreeMap<u32, ProcInfo>,
67}
68
69impl Default for ProcSnapshot {
70    fn default() -> Self {
71        Self {
72            at: Instant::now(),
73            uptime_s: 0.0,
74            procs: BTreeMap::new(),
75        }
76    }
77}
78
79impl ProcSnapshot {
80    pub fn collect() -> io::Result<Self> {
81        let mut system = System::new();
82        system.refresh_processes_specifics(ProcessesToUpdate::All, true, process_refresh_kind());
83        let boot_time_s = System::boot_time();
84        let procs = system
85            .processes()
86            .values()
87            .map(|process| {
88                let info = proc_info_from_sysinfo(process, boot_time_s);
89                (info.pid, info)
90            })
91            .collect();
92
93        Ok(Self {
94            at: Instant::now(),
95            uptime_s: System::uptime() as f64,
96            procs,
97        })
98    }
99
100    pub fn children_by_ppid(&self) -> HashMap<u32, Vec<u32>> {
101        children_by_ppid(&self.procs)
102    }
103
104    pub fn process_family(&self, root: u32) -> Vec<u32> {
105        process_family(root, &self.children_by_ppid(), &self.procs)
106    }
107
108    pub fn seeds_for_all(&self) -> Vec<PidSeed> {
109        self.procs.values().map(ProcInfo::seed).collect()
110    }
111
112    pub fn seeds_for_pid_family(&self, root: u32) -> Vec<PidSeed> {
113        self.process_family(root)
114            .into_iter()
115            .filter_map(|pid| self.procs.get(&pid).map(ProcInfo::seed))
116            .collect()
117    }
118
119    pub fn seeds_for_session(&self, session_id: u32) -> Vec<PidSeed> {
120        self.procs
121            .values()
122            .filter(|proc_info| proc_info.session_id == session_id)
123            .map(ProcInfo::seed)
124            .collect()
125    }
126
127    pub fn pids_in_session(&self, session_id: u32) -> Vec<u32> {
128        self.procs
129            .values()
130            .filter(|proc_info| proc_info.session_id == session_id)
131            .map(|proc_info| proc_info.pid)
132            .collect()
133    }
134}
135
136pub fn collect_fd_paths(process_trees: &[ProcessTree]) -> HashMap<ProcessKey, BTreeSet<PathBuf>> {
137    let mut out = HashMap::new();
138
139    for tree in process_trees {
140        for key in &tree.members {
141            if !process_key_is_current(*key) {
142                continue;
143            }
144            let paths = scan_proc_fd_paths(key.pid);
145            if !process_key_is_current(*key) {
146                continue;
147            }
148            if !paths.is_empty() {
149                out.insert(*key, paths);
150            }
151        }
152    }
153
154    out
155}
156
157fn process_key_is_current(key: ProcessKey) -> bool {
158    process_key_is_current_impl(key)
159}
160
161#[cfg(target_os = "linux")]
162fn process_key_is_current_impl(key: ProcessKey) -> bool {
163    process_starttime_ticks(key.pid) == Some(key.starttime_ticks)
164}
165
166#[cfg(not(target_os = "linux"))]
167fn process_key_is_current_impl(_key: ProcessKey) -> bool {
168    true
169}
170
171pub fn children_by_ppid(procs: &BTreeMap<u32, ProcInfo>) -> HashMap<u32, Vec<u32>> {
172    let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
173    for proc_info in procs.values() {
174        children
175            .entry(proc_info.ppid)
176            .or_default()
177            .push(proc_info.pid);
178    }
179    children
180}
181
182pub fn process_family(
183    root: u32,
184    children: &HashMap<u32, Vec<u32>>,
185    procs: &BTreeMap<u32, ProcInfo>,
186) -> Vec<u32> {
187    process_family_excluding(root, children, procs, &HashSet::new())
188}
189
190pub fn process_family_excluding(
191    root: u32,
192    children: &HashMap<u32, Vec<u32>>,
193    procs: &BTreeMap<u32, ProcInfo>,
194    excluded_roots: &HashSet<u32>,
195) -> Vec<u32> {
196    let mut out = Vec::new();
197    let mut stack = vec![root];
198    let mut seen = HashSet::new();
199    while let Some(pid) = stack.pop() {
200        if !seen.insert(pid) || !procs.contains_key(&pid) {
201            continue;
202        }
203        out.push(pid);
204        if let Some(child_pids) = children.get(&pid) {
205            stack.extend(
206                child_pids
207                    .iter()
208                    .copied()
209                    .filter(|child_pid| !excluded_roots.contains(child_pid)),
210            );
211        }
212    }
213    out
214}
215
216pub fn process_cpu_percent(
217    proc_info: &ProcInfo,
218    previous: Option<&ProcSnapshot>,
219    sample: &ProcSnapshot,
220) -> f64 {
221    let ticks_per_second = ticks_per_second();
222    if let Some(previous) = previous
223        && let Some(prev_proc) = previous.procs.get(&proc_info.pid)
224    {
225        let delta_ticks = proc_info.ticks.saturating_sub(prev_proc.ticks);
226        let delta_wall = sample.at.duration_since(previous.at).as_secs_f64();
227        if delta_wall > 0.0 {
228            return (delta_ticks as f64 / ticks_per_second) / delta_wall * 100.0;
229        }
230    }
231
232    let process_start_s = proc_info.starttime_ticks as f64 / ticks_per_second;
233    let elapsed_s = (sample.uptime_s - process_start_s).max(0.001);
234    (proc_info.ticks as f64 / ticks_per_second) / elapsed_s * 100.0
235}
236
237pub fn process_age_s(proc_info: &ProcInfo, sample: &ProcSnapshot) -> f64 {
238    let process_start_s = proc_info.starttime_ticks as f64 / ticks_per_second();
239    (sample.uptime_s - process_start_s).max(0.0)
240}
241
242fn proc_info_from_sysinfo(process: &Process, boot_time_s: u64) -> ProcInfo {
243    let pid = process.pid().as_u32();
244    let comm = process.name().to_string_lossy().into_owned();
245    let command = process_command(process, &comm);
246    let rss_bytes = process.memory();
247    let disk = process.disk_usage();
248    ProcInfo {
249        pid,
250        ppid: process.parent().map(pid_to_u32).unwrap_or_default(),
251        session_id: process.session_id().map(pid_to_u32).unwrap_or_default(),
252        comm,
253        command,
254        cwd: process.cwd().map(PathBuf::from),
255        ticks: cpu_ms_to_ticks(process.accumulated_cpu_time()),
256        starttime_ticks: platform_starttime_ticks(pid)
257            .unwrap_or_else(|| starttime_ticks_from_epoch(process.start_time(), boot_time_s)),
258        rss_kb: bytes_to_kb(rss_bytes),
259        rss_mb: bytes_to_mb(rss_bytes),
260        vsz_kb: bytes_to_kb(process.virtual_memory()),
261        threads: process
262            .tasks()
263            .map(|tasks| (tasks.len() as u32).saturating_add(1))
264            .unwrap_or(1),
265        read_bytes: disk.total_read_bytes,
266        write_bytes: disk.total_written_bytes,
267    }
268}
269
270fn process_refresh_kind() -> ProcessRefreshKind {
271    ProcessRefreshKind::nothing()
272        .with_memory()
273        .with_cpu()
274        .with_disk_usage()
275        .with_cmd(UpdateKind::Always)
276        .with_cwd(UpdateKind::Always)
277        .with_tasks()
278}
279
280fn process_command(process: &Process, fallback: &str) -> String {
281    let command = process
282        .cmd()
283        .iter()
284        .map(|arg| arg.to_string_lossy())
285        .collect::<Vec<_>>()
286        .join(" ");
287    if command.is_empty() {
288        fallback.to_string()
289    } else {
290        command
291    }
292}
293
294pub fn process_starttime_ticks(pid: u32) -> Option<u64> {
295    platform_starttime_ticks(pid).or_else(|| {
296        let sys_pid = Pid::from_u32(pid);
297        let mut system = System::new();
298        system.refresh_processes_specifics(
299            ProcessesToUpdate::Some(&[sys_pid]),
300            true,
301            ProcessRefreshKind::nothing(),
302        );
303        system
304            .process(sys_pid)
305            .map(|process| starttime_ticks_from_epoch(process.start_time(), System::boot_time()))
306    })
307}
308
309pub fn scan_proc_fd_paths(pid: u32) -> BTreeSet<PathBuf> {
310    let mut out = BTreeSet::new();
311    scan_proc_fd_paths_into(pid, &mut out);
312    out
313}
314
315#[cfg(target_os = "linux")]
316fn scan_proc_fd_paths_into(pid: u32, out: &mut BTreeSet<PathBuf>) {
317    let Ok(entries) = fs::read_dir(format!("/proc/{pid}/fd")) else {
318        return;
319    };
320    for entry in entries.flatten() {
321        let Ok(target) = fs::read_link(entry.path()) else {
322            continue;
323        };
324        out.insert(target);
325    }
326}
327
328#[cfg(target_os = "macos")]
329fn scan_proc_fd_paths_into(pid: u32, out: &mut BTreeSet<PathBuf>) {
330    let Ok(output) = std::process::Command::new(lsof_path())
331        .args(["-nP", "-Fn", "-p", &pid.to_string()])
332        .output()
333    else {
334        return;
335    };
336    if !output.status.success() {
337        return;
338    }
339    parse_lsof_file_names(&String::from_utf8_lossy(&output.stdout), out);
340}
341
342#[cfg(target_os = "macos")]
343fn lsof_path() -> &'static str {
344    if std::path::Path::new("/usr/sbin/lsof").is_file() {
345        "/usr/sbin/lsof"
346    } else {
347        "lsof"
348    }
349}
350
351#[cfg(not(any(target_os = "linux", target_os = "macos")))]
352fn scan_proc_fd_paths_into(_pid: u32, _out: &mut BTreeSet<PathBuf>) {}
353
354#[cfg(any(test, target_os = "macos"))]
355fn parse_lsof_file_names(output: &str, out: &mut BTreeSet<PathBuf>) {
356    for line in output.lines() {
357        let Some(path) = line.strip_prefix('n') else {
358            continue;
359        };
360        let path = path.trim().trim_end_matches(" (deleted)");
361        if path.starts_with('/') {
362            out.insert(PathBuf::from(path));
363        }
364    }
365}
366
367#[cfg(target_os = "linux")]
368fn platform_starttime_ticks(pid: u32) -> Option<u64> {
369    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
370    parse_proc_starttime_ticks(&stat)
371}
372
373#[cfg(not(target_os = "linux"))]
374fn platform_starttime_ticks(_pid: u32) -> Option<u64> {
375    None
376}
377
378#[cfg(target_os = "linux")]
379fn parse_proc_starttime_ticks(stat: &str) -> Option<u64> {
380    let close = stat.rfind(')')?;
381    stat[close + 1..].split_whitespace().nth(19)?.parse().ok()
382}
383
384fn bytes_to_kb(bytes: u64) -> u64 {
385    bytes / 1024
386}
387
388fn bytes_to_mb(bytes: u64) -> u64 {
389    if bytes == 0 {
390        0
391    } else {
392        bytes.div_ceil(1_048_576)
393    }
394}
395
396fn cpu_ms_to_ticks(cpu_ms: u64) -> u64 {
397    ((cpu_ms as f64 / 1000.0) * ticks_per_second()).round() as u64
398}
399
400fn pid_to_u32(pid: Pid) -> u32 {
401    pid.as_u32()
402}
403
404fn starttime_ticks_from_epoch(start_time_s: u64, boot_time_s: u64) -> u64 {
405    let since_boot_s = if start_time_s >= boot_time_s {
406        start_time_s - boot_time_s
407    } else {
408        start_time_s
409    };
410    ((since_boot_s as f64) * ticks_per_second()).round() as u64
411}
412
413pub fn process_start_timestamp_ms(starttime_ticks: u64) -> Option<u64> {
414    let boot_ms = u64::try_from(crate::time::get_boot_time_secs().saturating_mul(1000)).ok()?;
415    let process_offset_ms = ((starttime_ticks as f64 / ticks_per_second()) * 1000.0).round() as u64;
416    Some(boot_ms.saturating_add(process_offset_ms))
417}
418
419pub fn process_cpu_ms_delta(proc_info: &ProcInfo, previous: Option<&ProcSnapshot>) -> u64 {
420    let Some(previous) = previous else {
421        return 0;
422    };
423    let Some(prev_proc) = previous.procs.get(&proc_info.pid) else {
424        return 0;
425    };
426    if prev_proc.starttime_ticks != proc_info.starttime_ticks {
427        return 0;
428    }
429    let delta_ticks = proc_info.ticks.saturating_sub(prev_proc.ticks);
430    ((delta_ticks as f64 / ticks_per_second()) * 1000.0).round() as u64
431}
432
433fn ticks_per_second() -> f64 {
434    static TICKS_PER_SECOND: OnceLock<f64> = OnceLock::new();
435    *TICKS_PER_SECOND.get_or_init(|| {
436        let value = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
437        if value > 0 { value as f64 } else { 100.0 }
438    })
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn snapshot_collects_current_process() {
447        let snapshot = ProcSnapshot::collect().unwrap();
448        let current = snapshot.procs.get(&std::process::id()).unwrap();
449
450        assert_eq!(current.pid, std::process::id());
451        assert!(current.starttime_ticks > 0);
452        assert!(current.threads > 0);
453        assert!(!current.comm.is_empty() || !current.command.is_empty());
454    }
455
456    #[cfg(unix)]
457    #[test]
458    fn snapshot_counts_single_thread_process() {
459        let mut child = std::process::Command::new("sleep")
460            .arg("5")
461            .spawn()
462            .unwrap();
463        let snapshot = ProcSnapshot::collect().unwrap();
464        let threads = snapshot
465            .procs
466            .get(&child.id())
467            .map(|proc_info| proc_info.threads);
468        let _ = child.kill();
469        let _ = child.wait();
470
471        assert_eq!(threads, Some(1));
472    }
473
474    #[test]
475    fn starttime_ticks_accepts_epoch_or_boot_relative_seconds() {
476        let ticks = ticks_per_second().round() as u64;
477
478        assert_eq!(starttime_ticks_from_epoch(125, 100), 25 * ticks);
479        assert_eq!(starttime_ticks_from_epoch(25, 100), 25 * ticks);
480    }
481
482    #[cfg(target_os = "linux")]
483    #[test]
484    fn proc_fd_scan_finds_open_file_path() {
485        let temp = tempfile::tempdir().unwrap();
486        let path = temp.path().join("fd-evidence.txt");
487        let _file = std::fs::File::create(&path).unwrap();
488
489        let paths = scan_proc_fd_paths(std::process::id());
490        assert!(paths.contains(&path));
491    }
492
493    #[test]
494    fn lsof_parser_keeps_absolute_file_names() {
495        let mut out = BTreeSet::new();
496
497        parse_lsof_file_names(
498            "p123\nn/private/tmp/session.jsonl\nnlocalhost:1234\nn\n",
499            &mut out,
500        );
501
502        assert!(out.contains(&PathBuf::from("/private/tmp/session.jsonl")));
503        assert_eq!(out.len(), 1);
504    }
505}