Files
Starlight_Launcher/packages/app-lib/src/util/file_lock.rs
T

536 lines
16 KiB
Rust

//! 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<String>,
}
/// 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<LockingProcess> {
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<LockingProcess> {
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<Vec<LockingProcess>> {
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::<Vec<_>>();
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 <path>
/// ```
#[cfg(windows)]
fn parse_handle_exe_output(
output: &str,
path_str: &str,
) -> Vec<LockingProcess> {
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::<u32>() {
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<LockingProcess> {
let output = run_subprocess("fuser", &["-v", &file_path.to_string_lossy()]);
let pids: Vec<u32> = 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::<u32>().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<u32> {
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::<u32>()
&& pid > 0
{
pids.push(pid);
}
}
pids
}
#[cfg(target_os = "linux")]
fn read_proc_name(pid: u32) -> Option<String> {
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<String> {
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::<chrono::Utc>::from(dur);
Some(datetime.to_rfc3339())
}
// ─── macOS: lsof ───────────────────────────────────────────────────────────
#[cfg(target_os = "macos")]
fn get_locking_processes_macos(
file_path: &Path,
path_str: &str,
) -> Vec<LockingProcess> {
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<LockingProcess> {
let mut result = Vec::new();
let mut current_pid: Option<u32> = None;
let mut current_name: Option<String> = 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::<u32>().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<String> {
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());
}
}