use anyhow::{Context, Result, bail}; use std::path::PathBuf; /// The immutable operating-system identity of one process incarnation. /// /// A PID alone is an address which the OS is free to reuse. `creation_token` /// is the native, monotonic-within-boot incarnation token (Windows FILETIME or /// Linux `/proc//stat` starttime), and the executable path prevents a /// different image from inheriting authority through an erroneous launch. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProcessInstanceIdentity { pub process_id: u32, pub creation_token: u64, pub created_at_rfc3339: Option, pub executable_path: PathBuf, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum ProcessInstanceObservation { ExactAlive, ExactExited { exit_code: Option }, Replaced { observed: ProcessInstanceIdentity }, Missing, Inaccessible, Indeterminate { reason: String }, } /// Compatibility projection for old operator displays. It is deliberately /// incapable of calling access denial or PID absence "dead". #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EpiphanyProcessObservation { Alive, Missing, } impl EpiphanyProcessObservation { pub fn label(self) -> &'static str { match self { Self::Alive => "alive", Self::Missing => "missing", } } } pub fn capture_process_instance(process_id: u32) -> Result { if process_id == 0 { bail!("process-instance identity requires a nonzero PID"); } platform::capture(process_id) } pub fn observe_process_instance(expected: &ProcessInstanceIdentity) -> ProcessInstanceObservation { if expected.process_id == 0 || expected.creation_token == 0 { return ProcessInstanceObservation::Indeterminate { reason: "invalid expected process-instance identity".to_string(), }; } platform::observe(expected) } /// Returns a boot-incarnation token only when the platform can prove it. pub fn native_boot_identity() -> Option { platform::boot_identity() } pub fn native_process_executable_path(process_id: u32) -> Result> { match capture_process_instance(process_id) { Ok(identity) => Ok(Some(identity.executable_path)), Err(error) if platform::is_missing_error(&error) => Ok(None), Err(error) => Err(error), } } pub fn observe_native_process(process_id: u32) -> Result { // This legacy API has no expected incarnation and therefore cannot prove // termination. It remains useful only as a conservative liveness display. match capture_process_instance(process_id) { Ok(_) => Ok(EpiphanyProcessObservation::Alive), Err(error) if platform::is_missing_error(&error) => Ok(EpiphanyProcessObservation::Missing), Err(error) => Err(error), } } #[cfg(windows)] mod platform { use super::*; use chrono::{DateTime, SecondsFormat, Utc}; use std::os::windows::ffi::OsStringExt; use windows_sys::Win32::Foundation::{ CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, ERROR_NO_MORE_FILES, FILETIME, GetLastError, INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows_sys::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, }; use windows_sys::Win32::System::Threading::{ GetExitCodeProcess, GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, WaitForSingleObject, }; #[repr(C)] struct SystemTimeOfDayInformation { boot_time: i64, current_time: i64, time_zone_bias: i64, current_time_zone_id: u32, reserved: u32, boot_time_bias: u64, sleep_time_bias: u64, } #[link(name = "ntdll")] unsafe extern "system" { fn NtQuerySystemInformation( system_information_class: u32, system_information: *mut core::ffi::c_void, system_information_length: u32, return_length: *mut u32, ) -> i32; } const WINDOWS_TO_UNIX_100NS: u64 = 116_444_736_000_000_000; const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; struct OwnedHandle(windows_sys::Win32::Foundation::HANDLE); impl Drop for OwnedHandle { fn drop(&mut self) { unsafe { CloseHandle(self.0) }; } } fn filetime_token(value: FILETIME) -> u64 { ((value.dwHighDateTime as u64) << 32) | value.dwLowDateTime as u64 } fn rfc3339_from_filetime(token: u64) -> Result { let unix_100ns = token .checked_sub(WINDOWS_TO_UNIX_100NS) .context("process creation FILETIME predates Unix epoch")?; let seconds = (unix_100ns / 10_000_000) as i64; let nanos = ((unix_100ns % 10_000_000) * 100) as u32; let value = DateTime::::from_timestamp(seconds, nanos) .context("process creation FILETIME is out of range")?; Ok(value.to_rfc3339_opts(SecondsFormat::Nanos, true)) } fn pid_in_snapshot(process_id: u32) -> Result { unsafe { let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if snapshot == INVALID_HANDLE_VALUE { return Err(std::io::Error::last_os_error()).context("create process snapshot"); } let snapshot = OwnedHandle(snapshot); let mut entry: PROCESSENTRY32W = std::mem::zeroed(); entry.dwSize = std::mem::size_of::() as u32; if Process32FirstW(snapshot.0, &mut entry) == 0 { return Err(std::io::Error::last_os_error()).context("read process snapshot"); } loop { if entry.th32ProcessID == process_id { return Ok(true); } if Process32NextW(snapshot.0, &mut entry) == 0 { let error = GetLastError(); if error == ERROR_NO_MORE_FILES { return Ok(false); } return Err(std::io::Error::from_raw_os_error(error as i32)) .context("advance process snapshot"); } } } } fn open(process_id: u32) -> Result { unsafe { let handle = OpenProcess( PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE_ACCESS, 0, process_id, ); if handle.is_null() { return Err(std::io::Error::last_os_error()) .with_context(|| format!("open process {process_id}")); } Ok(OwnedHandle(handle)) } } fn identity_from_handle( process_id: u32, handle: &OwnedHandle, ) -> Result { unsafe { let mut creation: FILETIME = std::mem::zeroed(); let mut exit: FILETIME = std::mem::zeroed(); let mut kernel: FILETIME = std::mem::zeroed(); let mut user: FILETIME = std::mem::zeroed(); if GetProcessTimes(handle.0, &mut creation, &mut exit, &mut kernel, &mut user) == 0 { return Err(std::io::Error::last_os_error()).context("query process creation time"); } let creation_token = filetime_token(creation); if creation_token == 0 { bail!("operating system returned a zero process creation token"); } let mut buffer = vec![0_u16; 32_768]; let mut length = buffer.len() as u32; if QueryFullProcessImageNameW(handle.0, 0, buffer.as_mut_ptr(), &mut length) == 0 { return Err(std::io::Error::last_os_error()) .context("query process executable path"); } buffer.truncate(length as usize); let raw = PathBuf::from(std::ffi::OsString::from_wide(&buffer)); let executable_path = std::fs::canonicalize(&raw) .with_context(|| format!("canonicalize process executable {}", raw.display()))?; Ok(ProcessInstanceIdentity { process_id, creation_token, created_at_rfc3339: Some(rfc3339_from_filetime(creation_token)?), executable_path, }) } } pub(super) fn capture(process_id: u32) -> Result { identity_from_handle(process_id, &open(process_id)?) } pub(super) fn observe(expected: &ProcessInstanceIdentity) -> ProcessInstanceObservation { let present = match pid_in_snapshot(expected.process_id) { Ok(value) => value, Err(error) => { return ProcessInstanceObservation::Indeterminate { reason: error.to_string(), }; } }; if !present { return ProcessInstanceObservation::Missing; } let handle = match open(expected.process_id) { Ok(handle) => handle, Err(error) => { return if error .downcast_ref::() .is_some_and(|e| e.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32)) { ProcessInstanceObservation::Inaccessible } else { ProcessInstanceObservation::Indeterminate { reason: error.to_string(), } }; } }; let observed = match identity_from_handle(expected.process_id, &handle) { Ok(observed) if observed != *expected => { return ProcessInstanceObservation::Replaced { observed }; } Ok(observed) => observed, Err(error) => { return ProcessInstanceObservation::Indeterminate { reason: error.to_string(), }; } }; debug_assert_eq!(observed, *expected); unsafe { match WaitForSingleObject(handle.0, 0) { WAIT_OBJECT_0 => { let mut code = 0; let exit_code = (GetExitCodeProcess(handle.0, &mut code) != 0).then_some(code); ProcessInstanceObservation::ExactExited { exit_code } } WAIT_TIMEOUT => ProcessInstanceObservation::ExactAlive, WAIT_FAILED => ProcessInstanceObservation::Indeterminate { reason: format!( "wait for process failed with Windows error {}", GetLastError() ), }, other => ProcessInstanceObservation::Indeterminate { reason: format!("unexpected process wait result {other}"), }, } } } pub(super) fn boot_identity() -> Option { let mut information = SystemTimeOfDayInformation { boot_time: 0, current_time: 0, time_zone_bias: 0, current_time_zone_id: 0, reserved: 0, boot_time_bias: 0, sleep_time_bias: 0, }; let status = unsafe { NtQuerySystemInformation( 3, (&mut information as *mut SystemTimeOfDayInformation).cast(), std::mem::size_of::() as u32, std::ptr::null_mut(), ) }; (status >= 0 && information.boot_time > 0) .then(|| format!("windows-kernel-boot-filetime:{}", information.boot_time)) } pub(super) fn is_missing_error(error: &anyhow::Error) -> bool { error.chain().any(|source| { source.downcast_ref::().is_some_and(|e| { matches!(e.raw_os_error(), Some(code) if code == ERROR_INVALID_PARAMETER as i32) }) }) } } #[cfg(target_os = "linux")] mod platform { use super::*; fn stat(process_id: u32) -> Result<(u64, char)> { let raw = std::fs::read_to_string(format!("/proc/{process_id}/stat")) .with_context(|| format!("read /proc/{process_id}/stat"))?; let close = raw .rfind(") ") .context("malformed proc stat command field")?; let fields: Vec<&str> = raw[close + 2..].split_whitespace().collect(); let state = fields .first() .and_then(|value| value.chars().next()) .context("missing proc state")?; // starttime is field 22; `fields[0]` is original field 3. let starttime = fields.get(19).context("missing proc starttime")?.parse()?; Ok((starttime, state)) } pub(super) fn capture(process_id: u32) -> Result { let (creation_token, _) = stat(process_id)?; if creation_token == 0 { bail!("operating system returned a zero process creation token"); } let executable_path = std::fs::canonicalize(format!("/proc/{process_id}/exe")) .with_context(|| format!("read process {process_id} executable"))?; Ok(ProcessInstanceIdentity { process_id, creation_token, created_at_rfc3339: None, executable_path, }) } pub(super) fn observe(expected: &ProcessInstanceIdentity) -> ProcessInstanceObservation { let (creation_token, state) = match stat(expected.process_id) { Ok(observed) => observed, Err(error) if is_missing_error(&error) => return ProcessInstanceObservation::Missing, Err(error) => { return ProcessInstanceObservation::Indeterminate { reason: error.to_string(), }; } }; if creation_token == expected.creation_token && matches!(state, 'Z' | 'X') { return ProcessInstanceObservation::ExactExited { exit_code: None }; } match capture(expected.process_id) { Ok(observed) if observed != *expected => { ProcessInstanceObservation::Replaced { observed } } Ok(_) => ProcessInstanceObservation::ExactAlive, Err(error) => ProcessInstanceObservation::Indeterminate { reason: error.to_string(), }, } } pub(super) fn boot_identity() -> Option { std::fs::read_to_string("/proc/sys/kernel/random/boot_id") .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } pub(super) fn is_missing_error(error: &anyhow::Error) -> bool { error.chain().any(|source| { source .downcast_ref::() .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) }) } } #[cfg(not(any(windows, target_os = "linux")))] mod platform { use super::*; pub(super) fn capture(_: u32) -> Result { bail!("native process-instance observation is unsupported on this platform") } pub(super) fn observe(_: &ProcessInstanceIdentity) -> ProcessInstanceObservation { ProcessInstanceObservation::Indeterminate { reason: "native process-instance observation is unsupported on this platform" .to_string(), } } pub(super) fn boot_identity() -> Option { None } pub(super) fn is_missing_error(_: &anyhow::Error) -> bool { false } } #[cfg(test)] mod tests { use super::*; #[test] fn rejects_zero_pid() { assert!(capture_process_instance(0).is_err()); } #[test] fn native_boot_identity_is_available_and_stable() { let first = native_boot_identity().expect("native boot identity unavailable"); let second = native_boot_identity().expect("native boot identity disappeared"); assert_eq!(first, second); } #[test] fn live_current_process_has_stable_exact_identity() { let identity = capture_process_instance(std::process::id()).unwrap(); assert_ne!(identity.creation_token, 0); assert!(identity.executable_path.is_absolute()); assert_eq!( observe_process_instance(&identity), ProcessInstanceObservation::ExactAlive ); } #[test] fn forged_incarnation_is_replaced_not_alive() { let mut identity = capture_process_instance(std::process::id()).unwrap(); identity.creation_token = identity.creation_token.saturating_add(1); assert!(matches!( observe_process_instance(&identity), ProcessInstanceObservation::Replaced { .. } )); } #[test] fn child_exit_is_observed_for_the_exact_instance() { let mut child = if cfg!(windows) { std::process::Command::new("powershell.exe") .args([ "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Milliseconds 500; exit 17", ]) .spawn() .unwrap() } else { std::process::Command::new("sh") .args(["-c", "exit 17"]) .spawn() .unwrap() }; let identity = capture_process_instance(child.id()).unwrap(); let _ = child.wait().unwrap(); let observed = observe_process_instance(&identity); assert!(matches!( observed, ProcessInstanceObservation::ExactExited { exit_code: Some(17) } | ProcessInstanceObservation::Missing )); } }