This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
Epiphany/epiphany-core/src/process_observation.rs
2026-07-16 03:24:54 +01:00

481 lines
18 KiB
Rust

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/<pid>/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<String>,
pub executable_path: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProcessInstanceObservation {
ExactAlive,
ExactExited { exit_code: Option<u32> },
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<ProcessInstanceIdentity> {
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<String> {
platform::boot_identity()
}
pub fn native_process_executable_path(process_id: u32) -> Result<Option<PathBuf>> {
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<EpiphanyProcessObservation> {
// 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<String> {
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::<Utc>::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<bool> {
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::<PROCESSENTRY32W>() 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<OwnedHandle> {
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<ProcessInstanceIdentity> {
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<ProcessInstanceIdentity> {
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::<std::io::Error>()
.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<String> {
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::<SystemTimeOfDayInformation>() 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::<std::io::Error>().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<ProcessInstanceIdentity> {
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<String> {
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::<std::io::Error>()
.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<ProcessInstanceIdentity> {
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<String> {
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
));
}
}