pub const START_TIME_TOLERANCE_SECS: i64 = 2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Liveness {
Alive,
Dead,
Unknown,
}
#[cfg(target_os = "linux")]
mod platform {
use std::sync::OnceLock;
static BTIME: OnceLock<i64> = OnceLock::new();
fn btime() -> i64 {
*BTIME.get_or_init(|| {
(|| -> anyhow::Result<i64> {
let content = std::fs::read_to_string("/proc/stat")?;
for line in content.lines() {
if let Some(rest) = line.strip_prefix("btime ") {
return Ok(rest.trim().parse()?);
}
}
anyhow::bail!("btime line not found in /proc/stat")
})()
.unwrap_or(0)
})
}
pub fn process_comm(pid: u32) -> anyhow::Result<Option<String>> {
let path = format!("/proc/{pid}/comm");
match std::fs::read_to_string(&path) {
Ok(s) => Ok(Some(s.trim().to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
tracing::debug!(pid, path, "permission denied reading process comm");
Ok(None)
}
Err(e) => Err(e.into()),
}
}
pub fn process_executable_name(pid: u32) -> anyhow::Result<Option<String>> {
let path = format!("/proc/{pid}/exe");
match std::fs::read_link(&path) {
Ok(target) => Ok(target
.file_name()
.map(|name| name.to_string_lossy().into_owned())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
tracing::debug!(
pid,
path,
"permission denied reading process executable path"
);
Ok(None)
}
Err(e) => Err(e.into()),
}
}
pub fn process_started_at_unix(pid: u32) -> anyhow::Result<Option<i64>> {
let path = format!("/proc/{pid}/stat");
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
tracing::debug!(pid, "permission denied reading /proc/{pid}/stat");
return Ok(None);
}
Err(e) => return Err(e.into()),
};
let rparen = content
.rfind(')')
.ok_or_else(|| anyhow::anyhow!("malformed /proc/{pid}/stat: no closing ')' found"))?;
let after_comm = content.get(rparen + 2..).unwrap_or("");
let fields: Vec<&str> = after_comm.split_whitespace().collect();
let starttime_ticks: u64 = fields
.get(19)
.ok_or_else(|| anyhow::anyhow!("starttime field missing in /proc/{pid}/stat"))?
.parse()
.map_err(|e| anyhow::anyhow!("failed to parse starttime in /proc/{pid}/stat: {e}"))?;
let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
if clk_tck <= 0 {
anyhow::bail!("sysconf(_SC_CLK_TCK) returned {clk_tck}");
}
let bt = btime();
if bt == 0 {
anyhow::bail!("could not determine boot time from /proc/stat");
}
Ok(Some(bt + (starttime_ticks as i64 / clk_tck)))
}
}
#[cfg(target_os = "macos")]
mod platform {
pub fn process_comm(pid: u32) -> anyhow::Result<Option<String>> {
let output = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()?;
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if s.is_empty() {
return Ok(None);
}
let basename = std::path::Path::new(&s)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or(s);
Ok(Some(basename))
}
pub fn process_started_at_unix(pid: u32) -> anyhow::Result<Option<i64>> {
let output = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "lstart="])
.env("LANG", "C")
.env("LC_ALL", "C")
.output()?;
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if s.is_empty() {
return Ok(None);
}
super::parse_lstart(&s)
}
pub fn process_executable_name(pid: u32) -> anyhow::Result<Option<String>> {
process_comm(pid)
}
}
#[cfg(target_os = "macos")]
fn parse_lstart(s: &str) -> anyhow::Result<Option<i64>> {
use chrono::{Local, TimeZone};
let Some(naive_dt) = parse_lstart_naive(s) else {
return Ok(None);
};
match Local.from_local_datetime(&naive_dt).single() {
Some(dt) => Ok(Some(dt.timestamp())),
None => Ok(None),
}
}
#[cfg(any(test, target_os = "macos"))]
fn parse_lstart_naive(s: &str) -> Option<chrono::NaiveDateTime> {
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() != 5 {
return None;
}
let month: u32 = match parts[1] {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => return None,
};
let day: u32 = parts[2].parse().ok()?;
let year: i32 = parts[4].parse().ok()?;
let time_parts: Vec<&str> = parts[3].split(':').collect();
if time_parts.len() != 3 {
return None;
}
let hour: u32 = time_parts[0].parse().ok()?;
let min: u32 = time_parts[1].parse().ok()?;
let sec: u32 = time_parts[2].parse().ok()?;
let date = NaiveDate::from_ymd_opt(year, month, day)?;
let time = NaiveTime::from_hms_opt(hour, min, sec)?;
Some(NaiveDateTime::new(date, time))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
mod platform {
pub fn process_comm(_pid: u32) -> anyhow::Result<Option<String>> {
Ok(None)
}
pub fn process_started_at_unix(_pid: u32) -> anyhow::Result<Option<i64>> {
Ok(None)
}
pub fn process_executable_name(_pid: u32) -> anyhow::Result<Option<String>> {
Ok(None)
}
}
pub fn process_comm(pid: u32) -> anyhow::Result<Option<String>> {
platform::process_comm(pid)
}
pub fn process_started_at_unix(pid: u32) -> anyhow::Result<Option<i64>> {
platform::process_started_at_unix(pid)
}
pub fn process_executable_name(pid: u32) -> anyhow::Result<Option<String>> {
platform::process_executable_name(pid)
}
pub fn process_liveness(pid: u32) -> Liveness {
match process_comm(pid) {
Ok(Some(_)) => Liveness::Alive,
Ok(None) => Liveness::Dead,
Err(_) => Liveness::Unknown,
}
}
pub fn process_name_matches(pid: u32, expected_comm: &str) -> bool {
process_executable_name(pid)
.ok()
.flatten()
.is_some_and(|name| name == expected_comm)
|| process_comm(pid)
.ok()
.flatten()
.is_some_and(|name| name == expected_comm)
}
#[cfg(not(windows))]
pub fn validate_pid_matches(pid: u32, expected_comm: &str, expected_started_at_unix: i64) -> bool {
match process_started_at_unix(pid) {
Ok(Some(t)) => {
process_name_matches(pid, expected_comm)
&& (t - expected_started_at_unix).abs() <= START_TIME_TOLERANCE_SECS
}
_ => false,
}
}
pub fn current_process_start_time_unix() -> anyhow::Result<i64> {
process_started_at_unix(std::process::id())?
.ok_or_else(|| anyhow::anyhow!("could not determine start time of current process"))
}
#[cfg(test)]
mod tests {
use super::parse_lstart_naive;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
fn naive(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> NaiveDateTime {
NaiveDateTime::new(
NaiveDate::from_ymd_opt(year, month, day).unwrap(),
NaiveTime::from_hms_opt(hour, min, sec).unwrap(),
)
}
#[test]
fn parses_single_digit_day_padded_with_a_double_space() {
let parsed = parse_lstart_naive("Wed Sep 9 18:43:39 2026").unwrap();
assert_eq!(parsed, naive(2026, 9, 9, 18, 43, 39));
}
#[test]
fn parses_double_digit_day() {
let parsed = parse_lstart_naive("Mon Jan 12 09:05:00 2026").unwrap();
assert_eq!(parsed, naive(2026, 1, 12, 9, 5, 0));
}
#[test]
fn does_not_transpose_a_month_number_larger_than_any_day() {
let parsed = parse_lstart_naive("Thu Dec 3 00:00:00 2026").unwrap();
assert_eq!(parsed, naive(2026, 12, 3, 0, 0, 0));
}
#[test]
fn rejects_malformed_field_count() {
assert!(parse_lstart_naive("Sep 9 18:43:39 2026").is_none());
}
#[test]
fn rejects_unknown_month_name() {
assert!(parse_lstart_naive("Wed Foo 9 18:43:39 2026").is_none());
}
}