//! Detect processes that hold a file lock on a given path. //! //! Uses platform-specific lock inspection: //! - Windows: Restart Manager API, with `handle.exe` as a fallback //! - Linux: `fuser -v` with fallback to `lsof -t` //! - macOS: `lsof -F pcn` //! //! All operations gracefully return an empty vec on any error. #![allow(dead_code)] use std::path::Path; use serde::Serialize; use tracing::warn; #[cfg(windows)] use std::{collections::HashSet, os::windows::ffi::OsStrExt}; #[cfg(windows)] use windows::{ Win32::{ Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS}, System::RestartManager::{ CCH_RM_SESSION_KEY, RM_PROCESS_INFO, RmEndSession, RmGetList, RmRegisterResources, RmStartSession, }, }, core::{PCWSTR, PWSTR}, }; /// Information about a process that has a file handle open. #[derive(Debug, Clone, Serialize)] pub struct LockingProcess { pub pid: u32, pub name: String, pub path: String, /// ISO 8601 timestamp of process start time, if available. pub start_time: Option, } /// Detect processes currently holding the file at `file_path` open. /// /// Returns an empty vec when no locking processes are found, the platform /// cannot determine this, required tools are missing, or any error occurs. pub fn get_locking_processes(file_path: &Path) -> Vec { let path_str = file_path.to_string_lossy().to_string(); #[cfg(windows)] { let procs = get_locking_processes_windows(file_path, &path_str); if !procs.is_empty() { warn!( "Windows file lock detected on {}: {} process(es)", path_str, procs.len() ); } procs } #[cfg(target_os = "linux")] { let procs = get_locking_processes_linux(file_path, &path_str); if !procs.is_empty() { warn!( "Linux file lock detected on {}: {} process(es)", path_str, procs.len() ); } procs } #[cfg(target_os = "macos")] { let procs = get_locking_processes_macos(file_path, &path_str); if !procs.is_empty() { warn!( "macOS file lock detected on {}: {} process(es)", path_str, procs.len() ); } procs } #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] { let _ = path_str; Vec::new() } } // ─── Windows: Restart Manager / handle.exe fallback ─────────────────────── #[cfg(windows)] fn get_locking_processes_windows( file_path: &Path, path_str: &str, ) -> Vec { if let Some(processes) = get_locking_processes_windows_restart_manager(file_path, path_str) { return processes; } let output = run_subprocess("handle.exe", &["-a", &file_path.to_string_lossy()]); match output { Some(stdout) => parse_handle_exe_output(&stdout, path_str), None => Vec::new(), } } #[cfg(windows)] struct RestartManagerSession(u32); #[cfg(windows)] impl Drop for RestartManagerSession { fn drop(&mut self) { unsafe { let _ = RmEndSession(self.0); } } } #[cfg(windows)] fn get_locking_processes_windows_restart_manager( file_path: &Path, path_str: &str, ) -> Option> { let mut session_handle = 0; let mut session_key = [0u16; CCH_RM_SESSION_KEY as usize + 1]; let status = unsafe { RmStartSession( &raw mut session_handle, None, PWSTR(session_key.as_mut_ptr()), ) }; if status != ERROR_SUCCESS { warn!( "Restart Manager could not start a session for {}: error {}", path_str, status.0 ); return None; } let session = RestartManagerSession(session_handle); let wide_path = file_path .as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect::>(); let filenames = [PCWSTR(wide_path.as_ptr())]; let status = unsafe { RmRegisterResources(session.0, Some(&filenames), None, None) }; if status != ERROR_SUCCESS { warn!( "Restart Manager could not register {}: error {}", path_str, status.0 ); return None; } let mut needed = 0; let mut count = 0; let mut reboot_reasons = 0; let status = unsafe { RmGetList( session.0, &raw mut needed, &raw mut count, None, &raw mut reboot_reasons, ) }; if status == ERROR_SUCCESS && needed == 0 { return Some(Vec::new()); } if status != ERROR_MORE_DATA { warn!( "Restart Manager could not query {}: error {}", path_str, status.0 ); return None; } for _ in 0..3 { let mut process_info = vec![RM_PROCESS_INFO::default(); needed as usize]; count = needed; let status = unsafe { RmGetList( session.0, &raw mut needed, &raw mut count, Some(process_info.as_mut_ptr()), &raw mut reboot_reasons, ) }; if status == ERROR_MORE_DATA { continue; } if status != ERROR_SUCCESS { warn!( "Restart Manager could not read {}: error {}", path_str, status.0 ); return None; } process_info.truncate(count as usize); let mut seen_pids = HashSet::new(); let processes = process_info .into_iter() .filter_map(|process| { let pid = process.Process.dwProcessId; seen_pids.insert(pid).then(|| { let app_name = utf16_array_to_string(&process.strAppName); let service_name = utf16_array_to_string(&process.strServiceShortName); let name = if !app_name.is_empty() { app_name } else if !service_name.is_empty() { service_name } else { format!("PID {pid}") }; LockingProcess { pid, name, path: path_str.to_string(), start_time: None, } }) }) .collect(); return Some(processes); } warn!( "Restart Manager process list kept changing while querying {}", path_str ); None } #[cfg(windows)] fn utf16_array_to_string(value: &[u16]) -> String { let length = value .iter() .position(|character| *character == 0) .unwrap_or(value.len()); String::from_utf16_lossy(&value[..length]) } /// Parse handle.exe output to extract PID + process name. /// /// handle.exe output lines look like: /// ```text /// chrome.exe pid: 1234 type: File /// ``` #[cfg(windows)] fn parse_handle_exe_output( output: &str, path_str: &str, ) -> Vec { let mut result = Vec::new(); for line in output.lines() { let trimmed = line.trim(); if let Some(pid_start) = trimmed.find("pid:") { let after_pid = &trimmed[pid_start + 4..]; let pid_str = after_pid.split_whitespace().next().unwrap_or(""); if let Ok(pid) = pid_str.parse::() { let name = trimmed[..pid_start].trim().to_string(); result.push(LockingProcess { pid, name, path: path_str.to_string(), start_time: None, }); } } } result } // ─── Linux: fuser / lsof ─────────────────────────────────────────────────── #[cfg(target_os = "linux")] fn get_locking_processes_linux( file_path: &Path, path_str: &str, ) -> Vec { let output = run_subprocess("fuser", &["-v", &file_path.to_string_lossy()]); let pids: Vec = match output { Some(stderr) => parse_fuser_output(&stderr), None => { let out = run_subprocess("lsof", &["-t", &file_path.to_string_lossy()]); match out { Some(stdout) => stdout .lines() .filter_map(|l| l.trim().parse::().ok()) .collect(), None => return Vec::new(), } } }; pids.into_iter() .filter_map(|pid| { let name = read_proc_name(pid)?; let start_time = read_proc_start_time(pid); Some(LockingProcess { pid, name, path: path_str.to_string(), start_time, }) }) .collect() } #[cfg(target_os = "linux")] fn parse_fuser_output(stderr: &str) -> Vec { let mut pids = Vec::new(); for line in stderr.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with("USER") { // Skip the "USER PID ACCESS COMMAND" header line. continue; } let tokens: Vec<&str> = trimmed.split_whitespace().collect(); // `fuser -v` data lines look like "path: USER PID ACCESS COMMAND", // so the PID is the third token; tolerate layouts without the // path prefix by falling back to the second token. let pid_candidate = if tokens.first().is_some_and(|t| t.ends_with(':')) { tokens.get(2) } else { tokens.get(1) }; if let Some(pid_str) = pid_candidate && let Ok(pid) = pid_str.parse::() && pid > 0 { pids.push(pid); } } pids } #[cfg(target_os = "linux")] fn read_proc_name(pid: u32) -> Option { let cmdline_path = format!("/proc/{pid}/cmdline"); let content = std::fs::read_to_string(&cmdline_path).ok()?; let first = content.split('\0').next()?; if first.is_empty() { return None; } Some( std::path::Path::new(first) .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| first.to_string()), ) } #[cfg(target_os = "linux")] fn read_proc_start_time(pid: u32) -> Option { let stat_path = format!("/proc/{pid}/stat"); let content = std::fs::read_to_string(&stat_path).ok()?; let paren_end = content.rfind(')')?; let after_paren = &content[paren_end + 1..]; let fields: Vec<&str> = after_paren.split_whitespace().collect(); // Field 22 (1-indexed) = starttime = index 19 (0-indexed). let starttime_str = fields.get(19)?; let clock_ticks: u64 = starttime_str.parse().ok()?; let clk_tck = 100u64; let seconds_since_boot = clock_ticks / clk_tck; let stat_content = std::fs::read_to_string("/proc/stat").ok()?; let btime_line = stat_content.lines().find(|l| l.starts_with("btime "))?; let boot_time_secs: u64 = btime_line.strip_prefix("btime ")?.trim().parse().ok()?; let start_time_secs = boot_time_secs + seconds_since_boot; let dur = std::time::UNIX_EPOCH + std::time::Duration::from_secs(start_time_secs); let datetime = chrono::DateTime::::from(dur); Some(datetime.to_rfc3339()) } // ─── macOS: lsof ─────────────────────────────────────────────────────────── #[cfg(target_os = "macos")] fn get_locking_processes_macos( file_path: &Path, path_str: &str, ) -> Vec { let output = run_subprocess("lsof", &["-F", "pcn", &file_path.to_string_lossy()]); match output { Some(stdout) => parse_lsof_output(&stdout, path_str), None => Vec::new(), } } #[cfg(target_os = "macos")] fn parse_lsof_output(output: &str, path_str: &str) -> Vec { let mut result = Vec::new(); let mut current_pid: Option = None; let mut current_name: Option = None; for line in output.lines() { if line.is_empty() { continue; } match line.chars().next() { Some('p') => { if let (Some(pid), Some(name)) = (current_pid.take(), current_name.take()) { result.push(LockingProcess { pid, name, path: path_str.to_string(), start_time: None, }); } current_pid = line[1..].parse::().ok(); } Some('c') => { current_name = Some(line[1..].to_string()); } Some('n') => {} _ => {} } } if let (Some(pid), Some(name)) = (current_pid, current_name) { result.push(LockingProcess { pid, name, path: path_str.to_string(), start_time: None, }); } result } // ─── Shared subprocess helper ────────────────────────────────────────────── /// Run a subprocess with the given arguments, capturing combined output. /// Returns `None` if the command is not found or fails. fn run_subprocess(program: &str, args: &[&str]) -> Option { let result = std::process::Command::new(program).args(args).output(); match result { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let combined = if stdout.is_empty() { stderr } else { stdout }; if combined.is_empty() { None } else { Some(combined) } } _ => None, } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn test_unlocked_file() { let dir = tempdir().expect("temp dir"); let file_path = dir.path().join("test.txt"); std::fs::write(&file_path, "hello").expect("write file"); let processes = get_locking_processes(&file_path); assert!( processes .iter() .all(|process| process.pid != std::process::id()) ); } #[cfg(windows)] #[test] fn windows_restart_manager_decodes_process_name() { let value = [b'A' as u16, b'x' as u16, b'o' as u16, 0, b'X' as u16]; assert_eq!(utf16_array_to_string(&value), "Axo"); } #[test] fn test_nonexistent_file() { let path = Path::new("/tmp/this_file_does_not_exist_xyz.test"); let processes = get_locking_processes(path); assert!(processes.is_empty()); } #[test] fn test_directory_path() { let dir = tempdir().expect("temp dir"); let processes = get_locking_processes(dir.path()); let _ = processes; } #[cfg(target_os = "linux")] #[test] fn fuser_output_parses_pids_after_username() { let output = "\ USER PID ACCESS COMMAND /tmp/foo.jar: user 1234 f.... minecraft /tmp/foo.jar: root 4321 F.... java "; assert_eq!(parse_fuser_output(output), vec![1234, 4321]); } #[cfg(target_os = "linux")] #[test] fn fuser_output_without_locks_is_empty() { assert!(parse_fuser_output("").is_empty()); assert!(parse_fuser_output("USER PID ACCESS COMMAND").is_empty()); } }