Skip to main content

kovra_wrapper/
caller.rs

1//! Observe the **requesting process** — the parent that launched this kovra
2//! process — to populate [`kovra_core::ConfirmRequest::requesting_process`] (I16,
3//! §8.3).
4//!
5//! This is a **trusted, observed fact**: the parent pid comes from the kernel
6//! (`getppid`), and the executable name is read from the OS by pid. It is never
7//! sourced from untrusted requester input, so it cannot be spoofed by the agent
8//! whose request triggered the prompt. The human approving at the Touch ID /
9//! file-broker prompt therefore sees *who* is really asking (e.g.
10//! `node (pid 1234)`) rather than always "kovra".
11//!
12//! Why this lives in the wrapper (not core): observing a process is OS work, and
13//! `core` must stay free of process-observation logic (CLAUDE.md rule 4). Both
14//! the CLI (`kovra show`, private-key ops) and the wrapper (`kovra run`) call
15//! [`observe_parent`]; the CLI depends on `kovra-wrapper`, so it reuses this
16//! helper rather than duplicating it.
17//!
18//! Degradation: if the name cannot be read, we fall back to `pid <N>`. We never
19//! include anything but a process identity (executable name/path + pid) — no
20//! arguments, no environment — so this can never leak a secret value (I7/I12).
21
22/// A human-readable identity for the **parent** process of the current process.
23///
24/// Returns e.g. `node (pid 1234)` or `/opt/homebrew/bin/node (pid 1234)`, or
25/// just `pid 1234` when the executable name cannot be resolved. Returns `None`
26/// only if even the parent pid cannot be observed (not expected on supported
27/// hosts, but it fails soft rather than fabricating an identity).
28#[must_use]
29pub fn observe_parent() -> Option<String> {
30    let ppid = parent_pid()?;
31    match process_name(ppid) {
32        Some(name) if !name.is_empty() => Some(format!("{name} (pid {ppid})")),
33        _ => Some(format!("pid {ppid}")),
34    }
35}
36
37/// The parent process id, from the kernel. `None` only if unobservable.
38#[cfg(unix)]
39fn parent_pid() -> Option<i32> {
40    // SAFETY: `getppid` takes no arguments, has no preconditions, and cannot fail.
41    let ppid = unsafe { libc::getppid() };
42    if ppid > 0 { Some(ppid) } else { None }
43}
44
45#[cfg(not(unix))]
46fn parent_pid() -> Option<i32> {
47    None
48}
49
50/// Best-effort executable name/path for `pid`. Platform-specific; degrades to
51/// `None` when it cannot be read (caller then shows just the pid).
52#[cfg(target_os = "macos")]
53fn process_name(pid: i32) -> Option<String> {
54    // `proc_pidpath` fills an absolute executable path. We bind it directly
55    // (libc does not expose the libproc shim) and keep the call minimal.
56    const PROC_PIDPATHINFO_MAXSIZE: usize = 4096;
57    unsafe extern "C" {
58        fn proc_pidpath(
59            pid: libc::c_int,
60            buffer: *mut libc::c_void,
61            buffersize: u32,
62        ) -> libc::c_int;
63    }
64    let mut buf = vec![0u8; PROC_PIDPATHINFO_MAXSIZE];
65    // SAFETY: `buf` is a valid, writable allocation of `buf.len()` bytes; the
66    // call writes at most `buffersize` bytes and returns the count written.
67    let n = unsafe {
68        proc_pidpath(
69            pid as libc::c_int,
70            buf.as_mut_ptr() as *mut libc::c_void,
71            buf.len() as u32,
72        )
73    };
74    if n <= 0 {
75        return None;
76    }
77    buf.truncate(n as usize);
78    String::from_utf8(buf).ok().filter(|s| !s.is_empty())
79}
80
81/// Linux: read the executable name from `/proc/<pid>/comm` (the short name),
82/// falling back to `/proc/<pid>/exe` (the resolved path) when available.
83#[cfg(all(unix, not(target_os = "macos")))]
84fn process_name(pid: i32) -> Option<String> {
85    if let Ok(exe) = std::fs::read_link(format!("/proc/{pid}/exe")) {
86        if let Some(s) = exe.to_str() {
87            if !s.is_empty() {
88                return Some(s.to_string());
89            }
90        }
91    }
92    std::fs::read_to_string(format!("/proc/{pid}/comm"))
93        .ok()
94        .map(|s| s.trim_end().to_string())
95        .filter(|s| !s.is_empty())
96}
97
98#[cfg(not(unix))]
99fn process_name(_pid: i32) -> Option<String> {
100    None
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    // The helper degrades gracefully: on the test host it observes a real parent
108    // (the test harness / cargo), so it returns Some(_) and includes a pid. We do
109    // not assert a specific name (that varies by host), only the shape.
110    #[test]
111    fn observe_parent_returns_non_empty_identity_with_pid() {
112        let id = observe_parent().expect("a parent process should be observable on the test host");
113        assert!(!id.is_empty());
114        assert!(
115            id.contains("pid "),
116            "identity should always carry the observed pid, got {id:?}"
117        );
118    }
119
120    // It must never leak more than a process identity (no secret value): the
121    // returned string is just a name/path and a pid — assert it has no embedded
122    // NUL and is a single line.
123    #[test]
124    fn observed_identity_is_a_clean_single_line() {
125        let id = observe_parent().unwrap();
126        assert!(!id.contains('\0'));
127        assert!(!id.contains('\n'));
128    }
129}