Skip to main content

running_process/observer/
cmdline.rs

1//! #539 — read the live command line of any LaunchedProcessTree PID
2//! without admin privileges.
3//!
4//! Cross-platform dispatcher that calls into a per-OS no-admin primitive:
5//!
6//! - **Windows**: `NtQueryInformationProcess(ProcessCommandLineInformation=60)`.
7//!   Slice 3 of #539. Works for any PID the calling process has
8//!   `PROCESS_QUERY_LIMITED_INFORMATION` on, which is always true for
9//!   descendants of a process we spawned into our own Job Object on the
10//!   non-elevated default integrity level.
11//! - **Linux**: `/proc/<pid>/cmdline` — landing in slice 6 of #539.
12//! - **macOS**: `sysctl(KERN_PROCARGS2)` — landing in slice 8 of #539.
13//!
14//! Linux and macOS branches return [`std::io::ErrorKind::Unsupported`]
15//! until their respective slices land; the API surface is stable now so
16//! consumers (e.g. clud) can wire to it once.
17
18/// Read the live command line of `pid` using the negotiated no-admin
19/// per-OS primitive for the `LaunchedProcessTree` scope.
20///
21/// Returns the command line as a UTF-8 (potentially lossy on Windows
22/// where the source is UTF-16) `String`, or an `io::Error` if the PID
23/// cannot be opened, has already exited, or the kernel rejected the
24/// query.
25///
26/// On platforms where the backend hasn't shipped yet
27/// (`TraceScope::LaunchedProcessTree` cmdline backend for that OS is
28/// still `Unavailable`), returns `ErrorKind::Unsupported` with a reason
29/// that names the future slice. This lets downstream callers code
30/// against the stable surface today.
31pub fn read_process_cmdline(pid: u32) -> std::io::Result<String> {
32    #[cfg(target_os = "windows")]
33    {
34        windows_impl::read_process_cmdline(pid)
35    }
36    #[cfg(target_os = "linux")]
37    {
38        linux_impl::read_process_cmdline(pid)
39    }
40    #[cfg(target_os = "macos")]
41    {
42        macos_impl::read_process_cmdline(pid)
43    }
44    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
45    {
46        let _ = pid;
47        Err(std::io::Error::new(
48            std::io::ErrorKind::Unsupported,
49            "#539: no LaunchedProcessTree cmdline backend planned for this OS",
50        ))
51    }
52}
53
54#[cfg(target_os = "macos")]
55mod macos_impl {
56    //! macOS `sysctl(KERN_PROCARGS2)` implementation. Returns the
57    //! actual argv the kernel handed to `execve`, fully no-admin for
58    //! processes the calling user owns.
59    //!
60    //! Layout of the returned buffer (per `sys/sysctl.h` +
61    //! `bsd/kern/kern_sysctl.c` in xnu):
62    //!
63    //! ```text
64    //! [ argc (i32, host endianness) ]
65    //! [ exec_path (NUL-terminated UTF-8 string) ]
66    //! [ NUL padding to align to ptr-boundary ]
67    //! [ argv[0] (NUL-terminated) ]
68    //! [ argv[1] ... argv[argc-1] (each NUL-terminated) ]
69    //! [ envp[0] ... envp[N] (NUL-terminated; ignored here) ]
70    //! ```
71
72    const CTL_KERN: libc::c_int = 1;
73    const KERN_PROCARGS2: libc::c_int = 49;
74
75    pub(super) fn read_process_cmdline(pid: u32) -> std::io::Result<String> {
76        if pid == 0 {
77            return Err(std::io::Error::new(
78                std::io::ErrorKind::InvalidInput,
79                "pid 0 is the kernel scheduler — not queryable",
80            ));
81        }
82        let mut name: [libc::c_int; 3] =
83            [CTL_KERN, KERN_PROCARGS2, pid as libc::c_int];
84        // Size probe: pass null buf to learn the required length.
85        let mut len: libc::size_t = 0;
86        let r = unsafe {
87            libc::sysctl(
88                name.as_mut_ptr(),
89                3,
90                std::ptr::null_mut(),
91                &mut len,
92                std::ptr::null_mut(),
93                0,
94            )
95        };
96        if r != 0 {
97            return Err(std::io::Error::last_os_error());
98        }
99        if len < std::mem::size_of::<i32>() {
100            return Err(std::io::Error::other(format!(
101                "KERN_PROCARGS2 returned size={len}, smaller than argc header",
102            )));
103        }
104
105        let mut buf = vec![0u8; len];
106        let r = unsafe {
107            libc::sysctl(
108                name.as_mut_ptr(),
109                3,
110                buf.as_mut_ptr() as *mut libc::c_void,
111                &mut len,
112                std::ptr::null_mut(),
113                0,
114            )
115        };
116        if r != 0 {
117            return Err(std::io::Error::last_os_error());
118        }
119        buf.truncate(len);
120        parse_procargs2(&buf)
121    }
122
123    fn parse_procargs2(buf: &[u8]) -> std::io::Result<String> {
124        if buf.len() < std::mem::size_of::<i32>() {
125            return Ok(String::new());
126        }
127        let argc = i32::from_ne_bytes([buf[0], buf[1], buf[2], buf[3]]);
128        if argc <= 0 {
129            return Ok(String::new());
130        }
131        let mut cursor = std::mem::size_of::<i32>();
132        // Skip exec_path: bytes until first NUL.
133        while cursor < buf.len() && buf[cursor] != 0 {
134            cursor += 1;
135        }
136        // Skip the run of NUL padding the kernel inserts to align argv
137        // start to a pointer boundary.
138        while cursor < buf.len() && buf[cursor] == 0 {
139            cursor += 1;
140        }
141        // Read exactly argc argv strings, joining with spaces — mirrors
142        // the Windows NtQueryInformationProcess and Linux
143        // /proc/<pid>/cmdline conventions.
144        let mut argv: Vec<String> = Vec::with_capacity(argc as usize);
145        for _ in 0..argc {
146            if cursor >= buf.len() {
147                break;
148            }
149            let start = cursor;
150            while cursor < buf.len() && buf[cursor] != 0 {
151                cursor += 1;
152            }
153            argv.push(String::from_utf8_lossy(&buf[start..cursor]).into_owned());
154            // Skip the NUL terminator.
155            cursor = cursor.saturating_add(1);
156        }
157        Ok(argv.join(" "))
158    }
159
160    #[cfg(test)]
161    mod tests {
162        use super::parse_procargs2;
163
164        /// Build a KERN_PROCARGS2 buffer for argv = [exec, args...].
165        fn build_procargs2(exec_path: &str, argv: &[&str]) -> Vec<u8> {
166            let mut buf = Vec::new();
167            let argc = argv.len() as i32;
168            buf.extend_from_slice(&argc.to_ne_bytes());
169            buf.extend_from_slice(exec_path.as_bytes());
170            buf.push(0);
171            // Pad to a pointer boundary with extra NULs (kernel does
172            // this — exercise the skip-padding path in the parser).
173            while buf.len() % 8 != 0 {
174                buf.push(0);
175            }
176            for arg in argv {
177                buf.extend_from_slice(arg.as_bytes());
178                buf.push(0);
179            }
180            // Trailing envp would go here; we don't add any.
181            buf
182        }
183
184        #[test]
185        fn parses_argv_skipping_exec_path_and_padding() {
186            let buf = build_procargs2(
187                "/usr/bin/myprog",
188                &["myprog", "--flag", "value with space"],
189            );
190            let out = parse_procargs2(&buf).expect("parse");
191            assert_eq!(out, "myprog --flag value with space");
192        }
193
194        #[test]
195        fn empty_argv_yields_empty_string() {
196            let buf = build_procargs2("/usr/bin/noop", &[]);
197            let out = parse_procargs2(&buf).expect("parse");
198            assert_eq!(out, "");
199        }
200
201        #[test]
202        fn argc_zero_short_circuits() {
203            let mut buf = 0i32.to_ne_bytes().to_vec();
204            buf.extend_from_slice(b"/usr/bin/noop\0");
205            let out = parse_procargs2(&buf).expect("parse");
206            assert_eq!(out, "");
207        }
208    }
209}
210
211#[cfg(target_os = "linux")]
212mod linux_impl {
213    //! Linux `/proc/<pid>/cmdline` implementation. The kernel writes
214    //! argv as NUL-separated UTF-8 (typically — argv is opaque bytes,
215    //! we lossy-decode), with a trailing NUL.
216
217    pub(super) fn read_process_cmdline(pid: u32) -> std::io::Result<String> {
218        if pid == 0 {
219            return Err(std::io::Error::new(
220                std::io::ErrorKind::InvalidInput,
221                "pid 0 is the kernel scheduler — not queryable",
222            ));
223        }
224        let path = format!("/proc/{pid}/cmdline");
225        let bytes = std::fs::read(&path)?;
226        // `/proc/<pid>/cmdline` is empty for kernel threads — return
227        // empty string rather than synthesizing fake separators.
228        if bytes.is_empty() {
229            return Ok(String::new());
230        }
231        // Drop the trailing NUL terminator if present, then turn
232        // remaining NUL separators into spaces so the result reads as
233        // a single shell-style command line (same convention as
234        // Windows NtQueryInformationProcess and macOS KERN_PROCARGS2,
235        // both of which return one logical command line per PID).
236        let mut trimmed = bytes.as_slice();
237        if trimmed.last() == Some(&0) {
238            trimmed = &trimmed[..trimmed.len() - 1];
239        }
240        let joined: Vec<u8> = trimmed
241            .iter()
242            .map(|b| if *b == 0 { b' ' } else { *b })
243            .collect();
244        Ok(String::from_utf8_lossy(&joined).into_owned())
245    }
246}
247
248#[cfg(target_os = "windows")]
249mod windows_impl {
250    //! Windows `NtQueryInformationProcess(ProcessCommandLineInformation)`
251    //! implementation. The Info class is undocumented but stable on
252    //! Win8.1+ — empirically validated in clud#468 t03.
253
254    use std::ffi::c_void;
255
256    /// `ProcessCommandLineInformation` from `ntddk.h` — info class 60.
257    /// Stable since Windows 8.1. Returns a `UNICODE_STRING` header
258    /// followed by the inline wide-character cmdline bytes.
259    const PROCESS_COMMAND_LINE_INFORMATION: i32 = 60;
260
261    /// `STATUS_INFO_LENGTH_MISMATCH` (0xC0000004) — expected on the
262    /// initial size-probe call.
263    const STATUS_INFO_LENGTH_MISMATCH: i32 = 0xC0000004u32 as i32;
264
265    /// `STATUS_SUCCESS` (0).
266    const STATUS_SUCCESS: i32 = 0;
267
268    #[repr(C)]
269    struct UnicodeString {
270        length: u16,
271        maximum_length: u16,
272        buffer: *mut u16,
273    }
274
275    #[link(name = "ntdll")]
276    extern "system" {
277        fn NtQueryInformationProcess(
278            process_handle: *mut c_void,
279            process_information_class: i32,
280            process_information: *mut c_void,
281            process_information_length: u32,
282            return_length: *mut u32,
283        ) -> i32;
284    }
285
286    pub(super) fn read_process_cmdline(pid: u32) -> std::io::Result<String> {
287        use winapi::um::handleapi::CloseHandle;
288        use winapi::um::processthreadsapi::OpenProcess;
289        use winapi::um::winnt::PROCESS_QUERY_LIMITED_INFORMATION;
290
291        if pid == 0 {
292            return Err(std::io::Error::new(
293                std::io::ErrorKind::InvalidInput,
294                "pid 0 is the system idle process — not queryable",
295            ));
296        }
297
298        let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
299        if handle.is_null() {
300            return Err(std::io::Error::last_os_error());
301        }
302
303        let result = query_cmdline(handle as *mut c_void);
304        unsafe { CloseHandle(handle) };
305        result
306    }
307
308    fn query_cmdline(handle: *mut c_void) -> std::io::Result<String> {
309        // Size probe: pass a zero-length buffer; expect
310        // STATUS_INFO_LENGTH_MISMATCH and the required size in
311        // `needed`.
312        let mut needed: u32 = 0;
313        let status = unsafe {
314            NtQueryInformationProcess(
315                handle,
316                PROCESS_COMMAND_LINE_INFORMATION,
317                std::ptr::null_mut(),
318                0,
319                &mut needed,
320            )
321        };
322        if status != STATUS_INFO_LENGTH_MISMATCH && status != STATUS_SUCCESS {
323            return Err(std::io::Error::other(format!(
324                "NtQueryInformationProcess size probe returned status=0x{:08x}",
325                status as u32,
326            )));
327        }
328        if needed < std::mem::size_of::<UnicodeString>() as u32 {
329            return Err(std::io::Error::other(format!(
330                "NtQueryInformationProcess returned needed={needed}, smaller than UNICODE_STRING header",
331            )));
332        }
333
334        let mut buf = vec![0u8; needed as usize];
335        let mut returned: u32 = 0;
336        let status = unsafe {
337            NtQueryInformationProcess(
338                handle,
339                PROCESS_COMMAND_LINE_INFORMATION,
340                buf.as_mut_ptr() as *mut c_void,
341                needed,
342                &mut returned,
343            )
344        };
345        if status != STATUS_SUCCESS {
346            return Err(std::io::Error::other(format!(
347                "NtQueryInformationProcess returned status=0x{:08x}",
348                status as u32,
349            )));
350        }
351
352        // The buffer begins with a UNICODE_STRING whose `buffer` field
353        // points into the same allocation, immediately past the header.
354        // We cannot dereference `us.buffer` directly across the FFI
355        // boundary on systems that may relocate it; instead, compute the
356        // header size and read inline.
357        let us = unsafe { std::ptr::read(buf.as_ptr() as *const UnicodeString) };
358        let len_bytes = us.length as usize;
359        if len_bytes == 0 {
360            return Ok(String::new());
361        }
362        // The string is wide-char (UTF-16 LE) and located just after the
363        // UNICODE_STRING header. The kernel writes `buffer` as a pointer
364        // into our supplied allocation, but the safest portable parse is
365        // to read the chars from header_size..header_size+len_bytes in
366        // our own buffer.
367        let header_size = std::mem::size_of::<UnicodeString>();
368        if header_size + len_bytes > buf.len() {
369            return Err(std::io::Error::other(format!(
370                "NtQueryInformationProcess wrote less than {} bytes for cmdline (returned={returned}, len={len_bytes})",
371                header_size + len_bytes,
372            )));
373        }
374        let wide_slice: &[u16] = unsafe {
375            std::slice::from_raw_parts(buf[header_size..].as_ptr() as *const u16, len_bytes / 2)
376        };
377        Ok(String::from_utf16_lossy(wide_slice))
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
386    #[test]
387    fn read_cmdline_for_pid_zero_returns_invalid_input() {
388        // PID 0 is the system idle process on Windows / kernel scheduler
389        // on Linux + macOS — not openable from user-mode on any of them,
390        // so all three backends reject it up front before touching FFI /
391        // FS.
392        let err = read_process_cmdline(0).expect_err("pid 0 should be rejected");
393        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
394    }
395
396    #[cfg(any(target_os = "linux", target_os = "macos"))]
397    #[test]
398    fn unix_read_cmdline_round_trips_known_args_from_spawned_child() {
399        use crate::observer::ObserverConfig;
400        use crate::{CommandSpec, NativeProcess, ProcessConfig, StderrMode, StdinMode};
401        use std::time::Duration;
402
403        // Long-lived `sleep 30` (available on both Linux and macOS as a
404        // POSIX standard utility) with a distinctive argv: read it
405        // back via the per-OS no-admin primitive while the child is
406        // still alive.
407        let cfg = ProcessConfig {
408            command: CommandSpec::Argv(vec!["sleep".into(), "30".into()]),
409            cwd: None,
410            env: None,
411            capture: false,
412            stderr_mode: StderrMode::Stdout,
413            creationflags: None,
414            create_process_group: false,
415            stdin_mode: StdinMode::Inherit,
416            nice: None,
417        };
418        let (process, _sub) = NativeProcess::with_observer(cfg, ObserverConfig::lifecycle());
419        process.start().expect("spawn sleep");
420        let pid = process.pid().expect("pid");
421        std::thread::sleep(Duration::from_millis(100));
422
423        let cmdline = read_process_cmdline(pid).expect("read cmdline");
424        process.kill().ok();
425        process.close().ok();
426
427        assert!(
428            cmdline.contains("sleep"),
429            "expected 'sleep' in cmdline, got: {cmdline:?}"
430        );
431        assert!(
432            cmdline.contains("30"),
433            "expected '30' (the sleep duration) in cmdline, got: {cmdline:?}"
434        );
435    }
436
437    #[cfg(target_os = "linux")]
438    #[test]
439    fn linux_read_cmdline_for_nonexistent_pid_returns_not_found() {
440        let err = read_process_cmdline(0x7FFF_FFFE).expect_err("nonexistent pid");
441        // `/proc/<missing>/cmdline` open fails with ENOENT.
442        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
443    }
444
445    #[cfg(target_os = "macos")]
446    #[test]
447    fn macos_read_cmdline_for_nonexistent_pid_returns_io_error() {
448        // sysctl with a missing pid returns ESRCH; we surface it
449        // verbatim as the os_error code. Don't pin the exact errno
450        // because newer xnu builds occasionally remap it to EINVAL
451        // for hardened tasks; just assert an os_error came through.
452        let err = read_process_cmdline(0x7FFF_FFFE).expect_err("nonexistent pid");
453        assert!(
454            err.raw_os_error().is_some(),
455            "expected an OS-level errno, got: {err}"
456        );
457    }
458
459    #[cfg(target_os = "windows")]
460    #[test]
461    fn read_cmdline_for_unknown_pid_returns_io_error() {
462        // PID well above the typical Windows range — the OpenProcess
463        // should fail with INVALID_PARAMETER or NOT_FOUND, which we
464        // forward as the OS-level io::Error.
465        let err = read_process_cmdline(0x7FFF_FFFE).expect_err("nonexistent pid");
466        assert!(
467            err.raw_os_error().is_some(),
468            "expected an OS-level error code, got: {err}"
469        );
470    }
471
472    #[cfg(target_os = "windows")]
473    #[test]
474    fn read_cmdline_round_trips_known_args_from_spawned_child() {
475        use crate::observer::ObserverConfig;
476        use crate::{CommandSpec, NativeProcess, ProcessConfig, StderrMode, StdinMode};
477        use std::time::Duration;
478
479        // Spawn a long-lived child with a distinctive argv, read its
480        // cmdline back via NtQueryInformationProcess while it's still
481        // alive, and assert the readback contains our argv markers.
482        // `ping 127.0.0.1 -n 30` sleeps ~30s — plenty of time for the
483        // readback before the child exits and is reaped.
484        let cfg = ProcessConfig {
485            command: CommandSpec::Argv(vec![
486                "ping".into(),
487                "127.0.0.1".into(),
488                "-n".into(),
489                "30".into(),
490            ]),
491            cwd: None,
492            env: None,
493            capture: false,
494            stderr_mode: StderrMode::Stdout,
495            creationflags: None,
496            create_process_group: false,
497            stdin_mode: StdinMode::Inherit,
498            nice: None,
499        };
500        let (process, _sub) = NativeProcess::with_observer(cfg, ObserverConfig::lifecycle());
501        process.start().expect("spawn ping");
502        let pid = process.pid().expect("pid");
503        // Brief grace period so the process's PEB ProcessParameters is
504        // fully initialized before we query.
505        std::thread::sleep(Duration::from_millis(150));
506
507        let cmdline = read_process_cmdline(pid).expect("read cmdline");
508        process.kill().ok();
509        process.close().ok();
510
511        // Match relevant tokens — Windows command-line argv quoting
512        // and capitalization can vary, so just check substrings.
513        assert!(
514            cmdline.to_lowercase().contains("ping"),
515            "expected 'ping' in cmdline, got: {cmdline:?}"
516        );
517        assert!(
518            cmdline.contains("127.0.0.1"),
519            "expected target IP in cmdline, got: {cmdline:?}"
520        );
521        assert!(
522            cmdline.contains("30"),
523            "expected -n count in cmdline, got: {cmdline:?}"
524        );
525    }
526}