use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, ErrorKind, Read, Write}; use std::os::unix::fs::MetadataExt; #[cfg(target_os = "linux")] use std::os::fd::{FromRawFd, OwnedFd}; use std::path::{Path, PathBuf}; use nix::fcntl::{Flock, FlockArg, OFlag, openat}; use nix::sys::stat::Mode; use nix::unistd::{Pid, UnlinkatFlags, getpid, unlinkat}; use tracing::{debug, info, warn}; use super::DaemonError; use crate::util::secure_fs::AnchoredPath; /// PID file manager backed by a persistent sibling lock file. pub struct PidFile { path: PathBuf, lock_path: PathBuf, pid_file: Option, pid_identity: Option, lock_file: Option>, anchor: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct FileIdentity { device: u64, inode: u64, } impl FileIdentity { fn from_metadata(metadata: &fs::Metadata) -> Self { Self { device: metadata.dev(), inode: metadata.ino(), } } } impl PidFile { /// Creates a new PID file manager for the given path. pub fn new>(path: P) -> Self { let path = path.as_ref().to_path_buf(); let lock_path = sibling_lock_path(&path); Self { path, lock_path, pid_file: None, pid_identity: None, lock_file: None, anchor: None, } } /// Checks whether the PID file names a running process without modifying either file. pub fn check_running(&self) -> Result, DaemonError> { let Some(pid) = read_pid_file_if_exists(&self.path)? else { return Ok(None); }; Ok(is_process_running(pid).then_some(pid)) } /// Acquires the persistent sibling lock and writes the current PID. /// /// Fails if another owner holds the lock or the existing PID names a running process. pub fn acquire(&mut self) -> Result<(), DaemonError> { let anchor = AnchoredPath::open_trusted_parent_or_create(&self.path, 0o755).map_err( |error| { DaemonError::PidFile(format!( "cannot open trusted parent for {}: {}", self.path.display(), error )) }, )?; let lock_name = self.lock_path.file_name().ok_or_else(|| { DaemonError::PidFile(format!( "lock path {} has no file name", self.lock_path.display() )) })?; let lock_file = open_file_at( &anchor, lock_name, OFlag::O_RDWR | OFlag::O_CREAT | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, 0o644, ) .map_err(|error| { DaemonError::PidFile(format!( "cannot open lock file {}: {}", self.lock_path.display(), error )) })?; validate_regular_single_link(&lock_file, &self.lock_path)?; let lock_file = Flock::lock(lock_file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| { if let Some(pid) = read_pid_file_at(&anchor, &self.path) .ok() .flatten() .filter(|pid| is_process_running(*pid)) { DaemonError::AlreadyRunning(pid) } else { DaemonError::PidFile(format!( "cannot lock {}: {}", self.lock_path.display(), errno )) } })?; if let Some(pid) = read_pid_file_at(&anchor, &self.path)? && is_process_running(pid) { return Err(DaemonError::AlreadyRunning(pid)); } let mut pid_file = open_file_at( &anchor, anchor.name(), OFlag::O_RDWR | OFlag::O_CREAT | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, 0o644, ) .map_err(|error| { DaemonError::PidFile(format!("cannot open {}: {}", self.path.display(), error)) })?; let pid_metadata = validate_regular_single_link(&pid_file, &self.path)?; let pid_identity = FileIdentity::from_metadata(&pid_metadata); // Validate the opened inode before modifying it so a hard-link substitution // cannot turn PID publication into truncation of an unrelated file. pid_file.set_len(0).map_err(|error| { DaemonError::PidFile(format!("cannot truncate {}: {}", self.path.display(), error)) })?; let pid = getpid(); writeln!(pid_file, "{}", pid).map_err(|error| { DaemonError::PidFile(format!( "cannot write PID to {}: {}", self.path.display(), error )) })?; pid_file.sync_data().map_err(|error| { DaemonError::PidFile(format!( "cannot sync PID file {}: {}", self.path.display(), error )) })?; self.pid_file = Some(pid_file); self.pid_identity = Some(pid_identity); self.lock_file = Some(lock_file); self.anchor = Some(anchor); info!(pid = pid.as_raw(), path = %self.path.display(), "PID file created"); Ok(()) } /// Removes the PID file while retaining exclusive lock ownership until cleanup completes. pub fn release(&mut self) -> Result<(), DaemonError> { if self.lock_file.is_none() { self.pid_file = None; self.pid_identity = None; self.anchor = None; return Ok(()); } let removal = match self.anchor.as_ref() { Some(anchor) => remove_owned_pid_file(anchor, &self.path, self.pid_identity), None => Err(DaemonError::PidFile( "PID file lock is held without a directory anchor".to_string(), )), }; self.pid_file = None; self.pid_identity = None; self.lock_file = None; self.anchor = None; removal?; debug!(path = %self.path.display(), "PID file removed"); Ok(()) } /// Returns the path to this PID file. pub fn path(&self) -> &Path { &self.path } /// Returns open files whose ownership must follow the target runtime identity. pub(super) fn ownership_file_handles(&self) -> [Option<&File>; 2] { [self.pid_file.as_ref(), self.lock_file.as_deref()] } } impl Drop for PidFile { fn drop(&mut self) { if self.lock_file.is_some() && let Err(error) = self.release() { warn!(error = %error, "Failed to clean up PID file on drop"); } } } fn sibling_lock_path(path: &Path) -> PathBuf { let mut lock_path = path.as_os_str().to_os_string(); lock_path.push(".lock"); lock_path.into() } fn open_file_at( anchor: &AnchoredPath, name: &OsStr, flags: OFlag, mode: u32, ) -> io::Result { let descriptor = openat( anchor.parent(), name, flags, Mode::from_bits_truncate(mode), ) .map_err(|error| io::Error::from_raw_os_error(error as i32))?; Ok(File::from(descriptor)) } fn read_pid_file_if_exists(path: &Path) -> Result, DaemonError> { let anchor = match AnchoredPath::open_trusted_parent(path) { Ok(anchor) => anchor, Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), Err(error) => { return Err(DaemonError::PidFile(format!( "cannot open trusted parent for {}: {}", path.display(), error ))); } }; read_pid_file_at(&anchor, path) } fn read_pid_file_at(anchor: &AnchoredPath, path: &Path) -> Result, DaemonError> { let mut file = match open_file_at( anchor, anchor.name(), OFlag::O_RDONLY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, 0, ) { Ok(file) => file, Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), Err(error) => { return Err(DaemonError::PidFile(format!( "cannot read {}: {}", path.display(), error ))); } }; let metadata = validate_regular_single_link(&file, path)?; if metadata.len() > 64 { return Err(DaemonError::PidFile(format!( "invalid PID in {}", path.display() ))); } let mut contents = String::new(); file.read_to_string(&mut contents).map_err(|error| { DaemonError::PidFile(format!("cannot read {}: {}", path.display(), error)) })?; let pid: i32 = contents .trim() .parse() .map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", path.display())))?; if pid <= 1 { return Err(DaemonError::PidFile(format!( "invalid PID in {}", path.display() ))); } Ok(Some(pid)) } fn remove_owned_pid_file( anchor: &AnchoredPath, path: &Path, expected: Option, ) -> Result<(), DaemonError> { let file = match open_file_at( anchor, anchor.name(), OFlag::O_RDONLY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, 0, ) { Ok(file) => file, Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), Err(error) => { return Err(DaemonError::PidFile(format!( "cannot inspect {} before removal: {}", path.display(), error ))); } }; let metadata = validate_regular_single_link(&file, path)?; if expected != Some(FileIdentity::from_metadata(&metadata)) { return Err(DaemonError::PidFile(format!( "refusing to remove replaced PID file {}", path.display() ))); } drop(file); unlinkat( anchor.parent(), anchor.name(), UnlinkatFlags::NoRemoveDir, ) .map_err(|error| { DaemonError::PidFile(format!( "cannot remove {}: {}", path.display(), io::Error::from_raw_os_error(error as i32) )) }) } fn validate_regular_single_link( file: &File, path: &Path, ) -> Result { let metadata = file.metadata().map_err(|error| { DaemonError::PidFile(format!("cannot inspect {}: {}", path.display(), error)) })?; if !metadata.is_file() || metadata.nlink() != 1 { return Err(DaemonError::PidFile(format!( "{} must be a regular file with one directory entry", path.display() ))); } Ok(metadata) } /// Reads a PID from a PID file. #[allow(dead_code)] pub fn read_pid_file>(path: P) -> Result { let path = path.as_ref(); read_pid_file_if_exists(path)?.ok_or_else(|| { DaemonError::PidFile(format!( "cannot read {}: file does not exist", path.display() )) }) } /// Sends a signal to the process specified in a PID file. #[allow(dead_code)] pub fn signal_pid_file>( path: P, signal: nix::sys::signal::Signal, ) -> Result<(), DaemonError> { let path = path.as_ref(); let pid = read_pid_file(path)?; #[cfg(target_os = "linux")] let pidfd = open_pidfd(pid)?; if !daemon_lock_is_held(path)? { return Err(DaemonError::PidFile(format!( "refusing to signal unlocked or stale PID file {}", path.display() ))); } #[cfg(target_os = "linux")] return signal_pidfd(&pidfd, pid, signal); #[cfg(not(target_os = "linux"))] nix::sys::signal::kill(Pid::from_raw(pid), signal) .map_err(|error| DaemonError::PidFile(format!("cannot signal process {}: {}", pid, error))) } /// Daemon state derived from the PID file. #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub enum DaemonStatus { /// Daemon is running with the given PID. Running(i32), /// PID file exists but the named process is not running. Stale(i32), /// No readable PID file exists. NotRunning, } /// Checks daemon status without modifying the PID or lock file. #[allow(dead_code)] pub fn check_status>(path: P) -> DaemonStatus { let path = path.as_ref(); match read_pid_file_if_exists(path) { Ok(Some(pid)) if daemon_lock_is_held(path).unwrap_or(false) && is_process_running(pid) => { DaemonStatus::Running(pid) } Ok(Some(pid)) => DaemonStatus::Stale(pid), Ok(None) | Err(_) => DaemonStatus::NotRunning, } } fn daemon_lock_is_held(path: &Path) -> Result { let lock_path = sibling_lock_path(path); let anchor = match AnchoredPath::open_trusted_parent(path) { Ok(anchor) => anchor, Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), Err(error) => { return Err(DaemonError::PidFile(format!( "cannot open trusted parent for {}: {}", path.display(), error ))); } }; let lock_name = lock_path.file_name().ok_or_else(|| { DaemonError::PidFile(format!("lock path {} has no file name", lock_path.display())) })?; let file = match open_file_at( &anchor, lock_name, OFlag::O_RDWR | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, 0, ) { Ok(file) => file, Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), Err(error) => { return Err(DaemonError::PidFile(format!( "cannot inspect lock {}: {}", lock_path.display(), error ))); } }; validate_regular_single_link(&file, &lock_path)?; match Flock::lock(file, FlockArg::LockExclusiveNonblock) { Ok(_available) => Ok(false), Err((_file, nix::errno::Errno::EWOULDBLOCK)) => Ok(true), Err((_file, error)) => Err(DaemonError::PidFile(format!( "cannot inspect lock ownership for {}: {}", lock_path.display(), error ))), } } #[cfg(target_os = "linux")] fn open_pidfd(pid: i32) -> Result { // SAFETY: `pidfd_open` receives a validated positive PID and no pointer arguments. let descriptor = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; if descriptor < 0 { return Err(DaemonError::PidFile(format!( "cannot open stable process handle for {}: {}", pid, std::io::Error::last_os_error() ))); } // SAFETY: a successful `pidfd_open` returns one newly owned descriptor. Ok(unsafe { OwnedFd::from_raw_fd(descriptor as i32) }) } #[cfg(target_os = "linux")] fn signal_pidfd( pidfd: &OwnedFd, pid: i32, signal: nix::sys::signal::Signal, ) -> Result<(), DaemonError> { use std::os::fd::AsRawFd; // SAFETY: the pidfd is owned and valid, and both optional pointer arguments are null. let result = unsafe { libc::syscall( libc::SYS_pidfd_send_signal, pidfd.as_raw_fd(), signal as libc::c_int, std::ptr::null::(), 0, ) }; if result == 0 { Ok(()) } else { Err(DaemonError::PidFile(format!( "cannot signal process {} through stable handle: {}", pid, std::io::Error::last_os_error() ))) } } fn is_process_running(pid: i32) -> bool { nix::sys::signal::kill(Pid::from_raw(pid), None).is_ok() } #[cfg(test)] mod tests;