Skip to main content

running_process/observer/
file_handles.rs

1//! #539 — snapshot the file handles held by any LaunchedProcessTree
2//! PID without admin privileges.
3//!
4//! Cross-platform dispatcher matching the [`super::cmdline`] shape:
5//!
6//! - **Linux**: walk `/proc/<pid>/fd/*` and `readlink()` each entry.
7//!   Anonymous handles (`socket:[...]`, `pipe:[...]`, `anon_inode:...`)
8//!   are returned as opaque labels alongside real filesystem paths.
9//! - **macOS**: `proc_pidinfo(pid, PROC_PIDLISTFDS, ...)` enumerates
10//!   the fd table, then `proc_pidinfo(pid, PROC_PIDFDVNODEPATHINFO,
11//!   fd, ...)` resolves each vnode-backed fd to its filesystem path.
12//!   Sockets / pipes / kqueues without a path are skipped.
13//! - **Windows**: deferred to slice 4 of #539 (NtQuerySystemInformation
14//!   handle snapshot + DuplicateHandle + NtQueryObject — substantially
15//!   more involved than the Unix paths). Returns
16//!   [`ErrorKind::Unsupported`] with the slice anchor in the message.
17
18/// Snapshot the file handles currently held by `pid`, returned as
19/// human-readable strings (filesystem paths where possible,
20/// `socket:[...]` / `anon_inode:...` style labels otherwise).
21///
22/// The list is best-effort and racy by nature — handles open and
23/// close between the enumeration call and the per-fd lookup. Any fd
24/// that disappears mid-walk is silently skipped rather than failing
25/// the whole snapshot.
26pub fn read_process_file_handles(pid: u32) -> std::io::Result<Vec<String>> {
27    #[cfg(target_os = "linux")]
28    {
29        linux_impl::read_process_file_handles(pid)
30    }
31    #[cfg(target_os = "macos")]
32    {
33        macos_impl::read_process_file_handles(pid)
34    }
35    #[cfg(target_os = "windows")]
36    {
37        windows_impl::read_process_file_handles(pid)
38    }
39    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
40    {
41        let _ = pid;
42        Err(std::io::Error::new(
43            std::io::ErrorKind::Unsupported,
44            "#539: no LaunchedProcessTree handle-snapshot backend planned for this OS",
45        ))
46    }
47}
48
49#[cfg(target_os = "windows")]
50mod windows_impl {
51    //! Windows handle snapshot via
52    //! `NtQuerySystemInformation(SystemExtendedHandleInformation=64)`
53    //! filtered by PID, then `DuplicateHandle` + `NtQueryObject` to
54    //! resolve each File-typed handle's NT name.
55    //!
56    //! The size-doubling loop on `NtQuerySystemInformation` follows the
57    //! standard pattern: call with a buffer, grow on
58    //! `STATUS_INFO_LENGTH_MISMATCH`, retry. Filtering by
59    //! `UniqueProcessId == target_pid` happens after the call but
60    //! before the per-handle `DuplicateHandle` dance, so we never
61    //! actually touch external processes' handles — we just see their
62    //! presence in the system-wide table dump.
63    //!
64    //! `NtQueryObject(ObjectNameInformation)` can block indefinitely on
65    //! certain non-File handle types (named pipes to remote endpoints,
66    //! sockets to peer-disconnected sessions). We mitigate by first
67    //! calling `NtQueryObject(ObjectTypeInformation)` and skipping any
68    //! handle whose type name isn't `"File"`. ObjectTypeInformation is
69    //! safe to query on any handle type — it doesn't traverse the
70    //! object's name graph.
71
72    use std::ffi::c_void;
73
74    use winapi::shared::minwindef::FALSE;
75    use winapi::um::handleapi::CloseHandle;
76    use winapi::um::processthreadsapi::{GetCurrentProcess, OpenProcess};
77    use winapi::um::winnt::{DUPLICATE_SAME_ACCESS, HANDLE, PROCESS_DUP_HANDLE};
78
79    // ── Ntdll info classes ──
80
81    /// `SystemExtendedHandleInformation` (info class 64). Returns
82    /// `SYSTEM_HANDLE_INFORMATION_EX` (1 ULONG_PTR count + array of
83    /// `SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX`).
84    const SYSTEM_EXTENDED_HANDLE_INFORMATION: i32 = 64;
85
86    /// `ObjectTypeInformation` (info class 2). Returns a
87    /// `PUBLIC_OBJECT_TYPE_INFORMATION` (UNICODE_STRING TypeName +
88    /// opaque tail). Safe to call on any handle type.
89    const OBJECT_TYPE_INFORMATION: i32 = 2;
90
91    /// `ObjectNameInformation` (info class 1). Returns a
92    /// `PUBLIC_OBJECT_NAME_INFORMATION` (UNICODE_STRING Name).
93    /// **Hazard:** can block forever on certain non-File handles; we
94    /// guard by calling `ObjectTypeInformation` first.
95    const OBJECT_NAME_INFORMATION: i32 = 1;
96
97    const STATUS_SUCCESS: i32 = 0;
98    const STATUS_INFO_LENGTH_MISMATCH: i32 = 0xC0000004u32 as i32;
99
100    /// Layout matches `SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX` from
101    /// `<winternl.h>`. ULONG_PTR is pointer-sized (8 bytes on x86_64).
102    #[repr(C)]
103    #[derive(Copy, Clone)]
104    struct SystemHandleTableEntryInfoEx {
105        object: usize,            // PVOID
106        unique_process_id: usize, // ULONG_PTR
107        handle_value: usize,      // ULONG_PTR  (raw HANDLE value as integer)
108        granted_access: u32,
109        creator_back_trace_index: u16,
110        object_type_index: u16,
111        handle_attributes: u32,
112        reserved: u32,
113    }
114
115    /// Header for the buffer returned by
116    /// `NtQuerySystemInformation(SystemExtendedHandleInformation)`: a
117    /// single `ULONG_PTR` count followed by `count` entries.
118    #[repr(C)]
119    struct SystemHandleInformationExHeader {
120        number_of_handles: usize, // ULONG_PTR
121        reserved: usize,          // ULONG_PTR
122    }
123
124    /// Layout matches `UNICODE_STRING` from `<winternl.h>`.
125    #[repr(C)]
126    #[derive(Copy, Clone)]
127    struct UnicodeString {
128        length: u16,         // bytes (excluding NUL)
129        maximum_length: u16, // bytes
130        buffer: *mut u16,
131    }
132
133    #[link(name = "ntdll")]
134    extern "system" {
135        fn NtQuerySystemInformation(
136            system_information_class: i32,
137            system_information: *mut c_void,
138            system_information_length: u32,
139            return_length: *mut u32,
140        ) -> i32;
141
142        fn NtQueryObject(
143            handle: HANDLE,
144            object_information_class: i32,
145            object_information: *mut c_void,
146            object_information_length: u32,
147            return_length: *mut u32,
148        ) -> i32;
149    }
150
151    pub(super) fn read_process_file_handles(pid: u32) -> std::io::Result<Vec<String>> {
152        if pid == 0 {
153            return Err(std::io::Error::new(
154                std::io::ErrorKind::InvalidInput,
155                "pid 0 is the system idle process — not queryable",
156            ));
157        }
158
159        // 1. System-wide handle table dump (size-doubling loop).
160        let raw = query_system_handles()?;
161        let header_size = std::mem::size_of::<SystemHandleInformationExHeader>();
162        if raw.len() < header_size {
163            return Err(std::io::Error::other("NtQuerySystemInformation returned < header bytes"));
164        }
165        let header = unsafe {
166            std::ptr::read(raw.as_ptr() as *const SystemHandleInformationExHeader)
167        };
168        let entry_size = std::mem::size_of::<SystemHandleTableEntryInfoEx>();
169        let max_entries = (raw.len() - header_size) / entry_size;
170        let entries_count = std::cmp::min(header.number_of_handles, max_entries);
171
172        // 2. Open the target process for handle duplication. If this
173        // fails (most often because the process exited between the
174        // table dump and now, or we don't own the process), return
175        // the OS error — that's the correct behavior for the
176        // LaunchedProcessTree scope where we expect to own everything.
177        let target_proc = unsafe { OpenProcess(PROCESS_DUP_HANDLE, FALSE, pid) };
178        if target_proc.is_null() {
179            return Err(std::io::Error::last_os_error());
180        }
181        let target_guard = ProcHandle(target_proc);
182
183        // 3. Walk the entries, filter by pid, duplicate + type-check +
184        // name-query each surviving handle.
185        let mut handles = Vec::new();
186        let entries_ptr =
187            unsafe { raw.as_ptr().add(header_size) } as *const SystemHandleTableEntryInfoEx;
188        for i in 0..entries_count {
189            let entry = unsafe { std::ptr::read(entries_ptr.add(i)) };
190            if entry.unique_process_id as u32 != pid {
191                continue;
192            }
193            if let Some(path) = resolve_entry(target_guard.0, entry.handle_value as HANDLE) {
194                handles.push(path);
195            }
196        }
197        Ok(handles)
198    }
199
200    /// `NtQuerySystemInformation` size-doubling loop. Returns the raw
201    /// bytes (header + entries) so the caller can parse them.
202    fn query_system_handles() -> std::io::Result<Vec<u8>> {
203        // Start with 256 KB — typical Windows hosts have 50k–100k
204        // handles open across all processes; one ULONG_PTR + 28 bytes
205        // each is ~2.8 MB at the 100k mark, so we double aggressively.
206        let mut size: u32 = 256 * 1024;
207        loop {
208            let mut buf = vec![0u8; size as usize];
209            let mut returned: u32 = 0;
210            let status = unsafe {
211                NtQuerySystemInformation(
212                    SYSTEM_EXTENDED_HANDLE_INFORMATION,
213                    buf.as_mut_ptr() as *mut c_void,
214                    size,
215                    &mut returned,
216                )
217            };
218            if status == STATUS_SUCCESS {
219                let used = returned.max(1) as usize;
220                buf.truncate(used.min(buf.len()));
221                return Ok(buf);
222            }
223            if status == STATUS_INFO_LENGTH_MISMATCH {
224                // Double and retry. Cap at 256 MB to avoid runaway
225                // growth on a malicious / pathological host.
226                if size >= 256 * 1024 * 1024 {
227                    return Err(std::io::Error::other(format!(
228                        "NtQuerySystemInformation handle table exceeds 256 MiB (returned hint={returned})",
229                    )));
230                }
231                size = size.saturating_mul(2).max(returned.saturating_add(64 * 1024));
232                continue;
233            }
234            return Err(std::io::Error::other(format!(
235                "NtQuerySystemInformation returned status=0x{:08x}",
236                status as u32,
237            )));
238        }
239    }
240
241    /// Duplicate one foreign-process handle into the calling process,
242    /// check the object type, resolve the name if it's `"File"`, then
243    /// close the duplicated handle. Errors / non-File handles return
244    /// `None` rather than aborting the whole snapshot.
245    fn resolve_entry(target_proc: HANDLE, foreign_handle: HANDLE) -> Option<String> {
246        use winapi::um::handleapi::DuplicateHandle;
247        let mut local_handle: HANDLE = std::ptr::null_mut();
248        let ok = unsafe {
249            DuplicateHandle(
250                target_proc,
251                foreign_handle,
252                GetCurrentProcess(),
253                &mut local_handle,
254                0,
255                FALSE,
256                DUPLICATE_SAME_ACCESS,
257            )
258        };
259        if ok == FALSE || local_handle.is_null() {
260            return None;
261        }
262        let local_guard = ProcHandle(local_handle);
263
264        // Type-check first: ObjectTypeInformation is safe on any
265        // handle. ObjectNameInformation is NOT safe on
266        // pipes/sockets, so we filter before calling it.
267        let type_name = query_object_string(local_guard.0, OBJECT_TYPE_INFORMATION)?;
268        if type_name != "File" {
269            return None;
270        }
271        query_object_string(local_guard.0, OBJECT_NAME_INFORMATION).filter(|s| !s.is_empty())
272    }
273
274    /// Call `NtQueryObject(class, ...)` with a size-doubling loop,
275    /// extract the leading `UNICODE_STRING`, and decode it as UTF-8
276    /// (lossy on the rare invalid-surrogate edge). The buffer is
277    /// read from the local allocation, not the kernel-returned
278    /// `buffer` pointer, so we don't deref a kernel-side address.
279    fn query_object_string(handle: HANDLE, info_class: i32) -> Option<String> {
280        let mut size: u32 = 4 * 1024;
281        loop {
282            let mut buf = vec![0u8; size as usize];
283            let mut returned: u32 = 0;
284            let status = unsafe {
285                NtQueryObject(
286                    handle,
287                    info_class,
288                    buf.as_mut_ptr() as *mut c_void,
289                    size,
290                    &mut returned,
291                )
292            };
293            if status == STATUS_SUCCESS {
294                buf.truncate((returned as usize).min(buf.len()));
295                return parse_leading_unicode_string(&buf);
296            }
297            if status == STATUS_INFO_LENGTH_MISMATCH {
298                if size >= 1024 * 1024 {
299                    return None;
300                }
301                size = size.saturating_mul(2).max(returned);
302                continue;
303            }
304            return None;
305        }
306    }
307
308    /// Read the leading `UNICODE_STRING` from `buf` and return the
309    /// wide-char data as a `String`.
310    ///
311    /// We must trust `us.buffer` (the kernel-supplied pointer) rather
312    /// than assuming the string lives immediately after the header.
313    /// For `ProcessCommandLineInformation` the string is appended
314    /// directly, but for `PUBLIC_OBJECT_TYPE_INFORMATION` it lives
315    /// past 88 bytes of trailing `Reserved[22]` fields. The kernel
316    /// writes `us.buffer` as a pointer into our supplied allocation
317    /// regardless of where it chose to place the bytes.
318    fn parse_leading_unicode_string(buf: &[u8]) -> Option<String> {
319        let header_size = std::mem::size_of::<UnicodeString>();
320        if buf.len() < header_size {
321            return None;
322        }
323        let us = unsafe { std::ptr::read(buf.as_ptr() as *const UnicodeString) };
324        let len_bytes = us.length as usize;
325        if len_bytes == 0 || us.buffer.is_null() {
326            return Some(String::new());
327        }
328        // Sanity check: the kernel-supplied pointer should point
329        // inside our buf allocation. Reject otherwise to avoid an
330        // accidental wild deref.
331        let buf_start = buf.as_ptr() as usize;
332        let buf_end = buf_start + buf.len();
333        let buffer_addr = us.buffer as usize;
334        if buffer_addr < buf_start || buffer_addr.saturating_add(len_bytes) > buf_end {
335            return None;
336        }
337        let wide: &[u16] = unsafe {
338            std::slice::from_raw_parts(us.buffer as *const u16, len_bytes / 2)
339        };
340        Some(String::from_utf16_lossy(wide))
341    }
342
343    /// RAII wrapper that closes a HANDLE on drop. Used for both the
344    /// OpenProcess result and the per-entry DuplicateHandle result.
345    struct ProcHandle(HANDLE);
346    impl Drop for ProcHandle {
347        fn drop(&mut self) {
348            if !self.0.is_null() {
349                unsafe { CloseHandle(self.0) };
350            }
351        }
352    }
353}
354
355#[cfg(target_os = "linux")]
356mod linux_impl {
357    pub(super) fn read_process_file_handles(pid: u32) -> std::io::Result<Vec<String>> {
358        if pid == 0 {
359            return Err(std::io::Error::new(
360                std::io::ErrorKind::InvalidInput,
361                "pid 0 is the kernel scheduler — not queryable",
362            ));
363        }
364        let dir = format!("/proc/{pid}/fd");
365        let entries = std::fs::read_dir(&dir)?;
366        let mut handles = Vec::new();
367        for entry in entries {
368            let Ok(entry) = entry else { continue };
369            // Each entry is a symlink to either a filesystem path
370            // (e.g. /etc/hosts) or an anonymous kernel object
371            // (`socket:[12345]`, `pipe:[67890]`, `anon_inode:...`).
372            // `read_link` returns the target as a PathBuf — keep the
373            // raw lossy-decoded string so anonymous targets survive
374            // intact for downstream pattern-matching.
375            let Ok(target) = std::fs::read_link(entry.path()) else {
376                continue;
377            };
378            handles.push(target.to_string_lossy().into_owned());
379        }
380        Ok(handles)
381    }
382}
383
384#[cfg(target_os = "macos")]
385mod macos_impl {
386    // libc 0.2 exposes `proc_pidinfo` / `proc_pidfdinfo` on macOS but
387    // does NOT export `vnode_fdinfowithpath` / `proc_fdinfo` /
388    // PROC_PIDLISTFDS / PROC_PIDFDVNODEPATHINFO. Declare them inline
389    // from `<sys/proc_info.h>` — layouts and values have been
390    // ABI-stable since OS X 10.5.
391    const PROC_PIDLISTFDS: libc::c_int = 1;
392    const PROC_PIDFDVNODEPATHINFO: libc::c_int = 2;
393    const PROX_FDTYPE_VNODE: u32 = 1;
394    /// `MAXPATHLEN` from `<sys/syslimits.h>`.
395    const MAXPATHLEN: usize = 1024;
396
397    /// `struct proc_fdinfo { int32_t proc_fd; uint32_t proc_fdtype; }`.
398    /// 8 bytes; size of array entry returned by `proc_pidinfo(PROC_PIDLISTFDS)`.
399    #[repr(C)]
400    #[derive(Copy, Clone)]
401    struct ProcFdInfo {
402        proc_fd: i32,
403        proc_fdtype: u32,
404    }
405
406    /// Opaque buffer matching `vnode_fdinfowithpath` (24 byte
407    /// `proc_fileinfo` header + 152 byte `vnode_info` + 1024 byte
408    /// `vip_path` = 1200 bytes total). We only read `vip_path`,
409    /// which lives at offset 24 + 152 = 176.
410    const VNODE_FDINFOWITHPATH_SIZE: usize = 1200;
411    const VIP_PATH_OFFSET: usize = 176;
412
413    #[repr(C)]
414    struct VnodeFdInfoWithPath {
415        _opaque: [u8; VNODE_FDINFOWITHPATH_SIZE],
416    }
417
418    pub(super) fn read_process_file_handles(pid: u32) -> std::io::Result<Vec<String>> {
419        if pid == 0 {
420            return Err(std::io::Error::new(
421                std::io::ErrorKind::InvalidInput,
422                "pid 0 is the kernel scheduler — not queryable",
423            ));
424        }
425        // Size probe: PROC_PIDLISTFDS with null buffer returns required
426        // bytes for the proc_fdinfo array.
427        let size = unsafe {
428            libc::proc_pidinfo(
429                pid as libc::c_int,
430                PROC_PIDLISTFDS,
431                0,
432                std::ptr::null_mut(),
433                0,
434            )
435        };
436        if size <= 0 {
437            return Err(std::io::Error::last_os_error());
438        }
439        let entry_size = std::mem::size_of::<ProcFdInfo>();
440        let count = (size as usize) / entry_size;
441        let mut fds: Vec<ProcFdInfo> = vec![ProcFdInfo { proc_fd: 0, proc_fdtype: 0 }; count];
442        let written = unsafe {
443            libc::proc_pidinfo(
444                pid as libc::c_int,
445                PROC_PIDLISTFDS,
446                0,
447                fds.as_mut_ptr() as *mut libc::c_void,
448                (count * entry_size) as libc::c_int,
449            )
450        };
451        if written <= 0 {
452            return Err(std::io::Error::last_os_error());
453        }
454        let written_count = (written as usize) / entry_size;
455        fds.truncate(written_count);
456
457        let mut handles = Vec::new();
458        for fd in &fds {
459            // We only resolve vnode-backed fds (regular files,
460            // directories, devices). Sockets/pipes/kqueues have no
461            // POSIX path; skip them.
462            if fd.proc_fdtype != PROX_FDTYPE_VNODE {
463                continue;
464            }
465            let mut info: VnodeFdInfoWithPath = unsafe { std::mem::zeroed() };
466            let n = unsafe {
467                libc::proc_pidfdinfo(
468                    pid as libc::c_int,
469                    fd.proc_fd,
470                    PROC_PIDFDVNODEPATHINFO,
471                    &mut info as *mut VnodeFdInfoWithPath as *mut libc::c_void,
472                    std::mem::size_of::<VnodeFdInfoWithPath>() as libc::c_int,
473                )
474            };
475            if n <= 0 {
476                // fd closed between listfds and fdinfo — skip the race.
477                continue;
478            }
479            let path_bytes = &info._opaque[VIP_PATH_OFFSET..VIP_PATH_OFFSET + MAXPATHLEN];
480            let nul = path_bytes
481                .iter()
482                .position(|&b| b == 0)
483                .unwrap_or(path_bytes.len());
484            if nul == 0 {
485                continue;
486            }
487            let path = String::from_utf8_lossy(&path_bytes[..nul]).into_owned();
488            handles.push(path);
489        }
490        Ok(handles)
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
499    #[test]
500    fn read_handles_for_pid_zero_returns_invalid_input() {
501        let err = read_process_file_handles(0).expect_err("pid 0 should be rejected");
502        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
503    }
504
505    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
506    #[test]
507    fn self_snapshot_includes_a_temp_file_we_just_opened() {
508        // Open a temp file, snapshot our own fds, assert the temp
509        // file's path is in the result.
510        //
511        // Unix backends return POSIX paths that match `path` /
512        // `canonical` directly. The Windows backend returns NT
513        // object names like `\Device\HarddiskVolume3\Users\...\tmpXXXX`
514        // which won't match a DOS-style path equal-for-equal — so we
515        // also match on filename suffix, which is reliable across
516        // the NT/DOS path translation gap.
517        let tmp = tempfile::NamedTempFile::new().expect("tempfile");
518        let path = tmp.path().to_path_buf();
519        let canonical = std::fs::canonicalize(&path).unwrap_or(path.clone());
520        let filename = path
521            .file_name()
522            .and_then(|s| s.to_str())
523            .map(str::to_owned)
524            .unwrap_or_default();
525
526        let handles =
527            read_process_file_handles(std::process::id()).expect("read self handles");
528        let canonical_str = canonical.to_string_lossy();
529        let raw_str = path.to_string_lossy();
530        let found = handles.iter().any(|h| {
531            h == canonical_str.as_ref()
532                || h == raw_str.as_ref()
533                || (!filename.is_empty() && h.ends_with(&filename))
534        });
535        assert!(
536            found,
537            "expected temp file (filename={filename}, canonical={canonical_str}, raw={raw_str}) in handles, got {handles:?}",
538        );
539        // Drop tmp explicitly so it stays alive until after the snapshot.
540        drop(tmp);
541    }
542}