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}