#![allow(clippy::multiple_crate_versions)] //! Shared Windows-native helpers for Mercury Toolbox commands. mod sudo; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::env; #[cfg(windows)] use std::ffi::c_void; use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; #[cfg(windows)] use std::ptr::null_mut; use std::thread; use std::time::Duration; use common::CliError; use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, get_sockets_info}; use serde::Serialize; use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; use thiserror::Error; #[cfg(windows)] use windows_sys::Wdk::System::SystemInformation::NtQuerySystemInformation; #[cfg(windows)] use windows_sys::Win32::Foundation::{ CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, FALSE, HANDLE, INVALID_HANDLE_VALUE, }; #[cfg(windows)] use windows_sys::Win32::Storage::FileSystem::{ FILE_TYPE_DISK, GetFileType, GetFinalPathNameByHandleW, VOLUME_NAME_DOS, }; #[cfg(windows)] use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE}; pub use sudo::{ LaunchIdentity, LaunchRequest, LaunchResult, PrivilegeMode, ProcessPriority, ShowWindowMode, TokenIntegrity, TokenStatus, attach_parent_console, current_token_status, elevate_current_process, launch_request, }; /// Represents a captured environment snapshot. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct EnvironmentSnapshot { /// Variable values keyed by name. pub values: BTreeMap, } /// Describes an added environment variable. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct EnvAdded { /// Variable name. pub name: String, /// Variable value after the change. pub value: String, } /// Describes a removed environment variable. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct EnvRemoved { /// Variable name. pub name: String, /// Variable value before removal. pub value: String, } /// Describes a changed environment variable. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct EnvChanged { /// Variable name. pub name: String, /// Value before the change. pub before: String, /// Value after the change. pub after: String, } /// Describes path-like segment changes for a variable. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PathLikeChange { /// Variable name. pub name: String, /// Segment count before the change. pub before_segment_count: usize, /// Segment count after the change. pub after_segment_count: usize, /// Segments added to the path-like variable. pub added_segments: Vec, /// Segments removed from the path-like variable. pub removed_segments: Vec, } /// Stable environment diff shape used by `envdiff`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct EnvironmentDiff { /// Variables only present after execution. pub added: Vec, /// Variables removed by execution. pub removed: Vec, /// Variables present in both snapshots with changed values. pub changed: Vec, /// Path-like segment additions/removals for common variables. pub path_like_changes: Vec, } /// Process metadata used by `proctree` and `unlock`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ProcessDescriptor { /// Process identifier. pub pid: u32, /// Parent process identifier when known. pub parent_pid: Option, /// Executable image name. pub image_name: String, /// Full executable path when known. pub exe: Option, /// Full command line. pub command_line: Vec, /// Process start time as seconds since UNIX epoch when known. pub start_time_unix: u64, /// Process runtime in seconds. pub run_time_seconds: u64, } /// Process identity used to re-check a PID before forceful termination. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] pub struct ProcessKillTarget { /// Process identifier to terminate. pub pid: u32, /// Process start time captured with the PID, when known. pub start_time_unix: Option, } /// Locker metadata returned by Restart Manager. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct LockerProcess { /// Locked path that triggered this process entry. pub path: String, /// Process identifier. pub pid: u32, /// Process start time captured with the PID, when known. pub start_time_unix: Option, /// Application name reported by Restart Manager. pub app_name: String, /// Service short name when applicable. pub service_name: String, /// Whether Restart Manager considers this app restartable. pub restartable: bool, /// Process image name from a live process snapshot when available. pub image_name: Option, /// Full process command line when known. pub command_line: Vec, } /// Query depth used by file-locker inspection helpers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LockerQueryMode { /// Prefer low-latency sources that are good enough for interactive inspection. Fast, /// Use every available source, including global handle scans, for maximum coverage. Deep, } /// Supported local port ownership protocols. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum PortProtocol { /// TCP socket ownership. Tcp, /// UDP socket ownership. Udp, } /// Port ownership enriched with local process metadata. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PortOwner { /// Local port number. pub port: u16, /// Socket protocol. pub protocol: PortProtocol, /// Local bind address. pub local_address: String, /// TCP state when available. pub state: Option, /// Owning process identifier. pub pid: u32, /// Process start time captured with the PID, when known. pub start_time_unix: Option, /// Executable image name when known. pub image_name: Option, /// Service label when known. pub service_name: Option, /// Full process command line when known. pub command_line: Vec, } /// Errors produced by the shared Windows helpers. #[derive(Debug, Error)] pub enum WindowsSupportError { /// Restart Manager failed for the given action. #[error("restart manager {action} failed with code {code}")] RestartManager { /// Operation label. action: &'static str, /// Raw Windows error code. code: u32, }, /// A filesystem action failed. #[error("{0}")] Io(String), /// A process action failed. #[error("{0}")] Process(String), /// A direct Windows API call failed. #[error("{action} failed with code {code}")] WindowsApi { /// Operation label. action: &'static str, /// Raw Windows error code. code: u32, }, /// The requested operation is unsupported in the current context. #[error("{0}")] Unsupported(String), } /// Captures the current process environment. #[must_use] pub fn capture_environment() -> EnvironmentSnapshot { let values = std::env::vars().collect::>(); EnvironmentSnapshot { values } } /// Reads a line-based environment snapshot file in `NAME=VALUE` format. /// /// # Errors /// /// Returns an error when the file cannot be read. pub fn read_environment_file(path: &Path) -> Result { let content = fs::read_to_string(path).map_err(|error| { WindowsSupportError::Io(format!("failed to read {}: {error}", path.display())) })?; let values = content .lines() .filter_map(|line| line.split_once('=')) .map(|(name, value)| (name.to_string(), value.to_string())) .collect::>(); Ok(EnvironmentSnapshot { values }) } /// Computes a stable environment diff between two snapshots. #[must_use] pub fn diff_environments( before: &EnvironmentSnapshot, after: &EnvironmentSnapshot, ) -> EnvironmentDiff { let mut added = Vec::new(); let mut removed = Vec::new(); let mut changed = Vec::new(); let mut path_like_changes = Vec::new(); let all_names = before .values .keys() .chain(after.values.keys()) .cloned() .collect::>(); for name in all_names { match (before.values.get(&name), after.values.get(&name)) { (None, Some(value)) => added.push(EnvAdded { name: name.clone(), value: value.clone(), }), (Some(value), None) => removed.push(EnvRemoved { name: name.clone(), value: value.clone(), }), (Some(before_value), Some(after_value)) if before_value != after_value => { changed.push(EnvChanged { name: name.clone(), before: before_value.clone(), after: after_value.clone(), }); if is_path_like_name(&name) { let before_segments = split_path_like(before_value); let after_segments = split_path_like(after_value); path_like_changes.push(PathLikeChange { name, before_segment_count: before_segments.len(), after_segment_count: after_segments.len(), added_segments: after_segments .iter() .filter(|segment| !before_segments.contains(*segment)) .cloned() .collect(), removed_segments: before_segments .iter() .filter(|segment| !after_segments.contains(*segment)) .cloned() .collect(), }); } } (Some(_), Some(_)) | (None, None) => {} } } EnvironmentDiff { added, removed, changed, path_like_changes, } } /// Captures a fresh process snapshot. #[must_use] pub fn snapshot_processes() -> Vec { let mut system = System::new(); let _ = system.refresh_processes_specifics( ProcessesToUpdate::All, true, ProcessRefreshKind::nothing() .with_cmd(UpdateKind::Always) .with_exe(UpdateKind::Always), ); let mut processes = system .processes() .values() .map(|process| ProcessDescriptor { pid: process.pid().as_u32(), parent_pid: process.parent().map(Pid::as_u32), image_name: process.name().to_string_lossy().to_string(), exe: process.exe().map(|path| path.display().to_string()), command_line: process .cmd() .iter() .map(|arg| arg.to_string_lossy().to_string()) .collect(), start_time_unix: process.start_time(), run_time_seconds: process.run_time(), }) .collect::>(); processes.sort_by_key(|process| process.pid); processes } /// Captures local TCP and UDP port owners using native socket tables. /// /// # Errors /// /// Returns an error when the socket table snapshot cannot be read. pub fn snapshot_port_owners() -> Result, WindowsSupportError> { let process_map = snapshot_processes() .into_iter() .map(|process| (process.pid, process)) .collect::>(); let sockets = get_sockets_info( AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6, ProtocolFlags::TCP | ProtocolFlags::UDP, ) .map_err(|error| { WindowsSupportError::Process(format!("failed to read socket tables: {error}")) })?; let mut owners = Vec::new(); for socket in sockets { let pids = if socket.associated_pids.is_empty() { vec![0_u32] } else { socket.associated_pids }; for pid in pids { let process = process_map.get(&pid); match socket.protocol_socket_info { ProtocolSocketInfo::Tcp(ref tcp) => owners.push(PortOwner { port: tcp.local_port, protocol: PortProtocol::Tcp, local_address: tcp.local_addr.to_string(), state: Some(tcp.state.to_string()), pid, start_time_unix: process.map(|process| process.start_time_unix), image_name: process.map(|process| process.image_name.clone()), service_name: None, command_line: process .map(|process| process.command_line.clone()) .unwrap_or_default(), }), ProtocolSocketInfo::Udp(ref udp) => owners.push(PortOwner { port: udp.local_port, protocol: PortProtocol::Udp, local_address: udp.local_addr.to_string(), state: None, pid, start_time_unix: process.map(|process| process.start_time_unix), image_name: process.map(|process| process.image_name.clone()), service_name: None, command_line: process .map(|process| process.command_line.clone()) .unwrap_or_default(), }), } } } owners.sort_by(|left, right| { ( left.port, port_protocol_order(left.protocol), left.pid, left.local_address.as_str(), ) .cmp(&( right.port, port_protocol_order(right.protocol), right.pid, right.local_address.as_str(), )) }); owners.dedup_by(|left, right| { left.port == right.port && left.protocol == right.protocol && left.pid == right.pid && left.local_address == right.local_address }); Ok(owners) } /// Resolves locker processes for the given path using the requested query depth. /// /// # Errors /// /// Returns an error when Restart Manager cannot inspect the path. pub fn query_file_lockers( path: &Path, mode: LockerQueryMode, ) -> Result, WindowsSupportError> { let process_map = snapshot_processes() .into_iter() .map(|process| (process.pid, process)) .collect::>(); let script = format!( "{}\n[MercuryRestartManager]::Who('{}') | ConvertTo-Json -Compress", RESTART_MANAGER_SCRIPT, escape_pwsh_single_quoted(&path.display().to_string()) ); let restart_manager_lockers = invoke_pwsh_json::(&script).map(|value| { let infos = match value { serde_json::Value::Array(items) => items .into_iter() .filter_map(|item| serde_json::from_value::(item).ok()) .collect::>(), serde_json::Value::Object(_) => serde_json::from_value::(value) .map(|item| vec![item]) .unwrap_or_default(), _ => Vec::new(), }; infos .into_iter() .map(|info| { let pid = info.pid; let process = process_map.get(&pid); LockerProcess { path: path.display().to_string(), pid, start_time_unix: process.map(|process| process.start_time_unix), app_name: info.app_name, service_name: info.service_name, restartable: info.restartable, image_name: process.map(|process| process.image_name.clone()), command_line: process .map(|process| process.command_line.clone()) .unwrap_or_default(), } }) .collect::>() }); let fallback = fallback_lockers(path, &process_map); match mode { LockerQueryMode::Fast => resolve_fast_locker_query(restart_manager_lockers, fallback), LockerQueryMode::Deep => { let handle_lockers = scan_handle_lockers(path, &process_map); resolve_deep_locker_query(restart_manager_lockers, handle_lockers, fallback) } } } /// Requests a graceful release for any lockers on the given paths. /// /// # Errors /// /// Returns an error when Restart Manager cannot complete the shutdown request. pub fn graceful_release(paths: &[PathBuf]) -> Result<(), WindowsSupportError> { if paths.is_empty() { return Ok(()); } let list = format_pwsh_array(paths); let script = format!( "{RESTART_MANAGER_SCRIPT}\n[void][MercuryRestartManager]::Shutdown({list}, $false)" ); invoke_pwsh_status(&script) } /// Forces remaining lockers for the given paths to exit. /// /// # Errors /// /// Returns an error when a blocker process cannot be terminated. pub fn force_release(paths: &[PathBuf]) -> Result<(), WindowsSupportError> { force_release_with_locker_query(paths, query_file_lockers) } fn force_release_with_locker_query( paths: &[PathBuf], locker_query: Q, ) -> Result<(), WindowsSupportError> where Q: Fn(&Path, LockerQueryMode) -> Result, WindowsSupportError>, { let blockers = paths .iter() .map(|path| locker_query(path, LockerQueryMode::Fast)) .collect::, _>>()? .into_iter() .flatten() .map(|locker| ProcessKillTarget { pid: locker.pid, start_time_unix: locker.start_time_unix, }) .collect::>(); let blocker_list = blockers.into_iter().collect::>(); terminate_processes_checked(&blocker_list) } /// Terminates the exact process identifiers provided. /// /// # Errors /// /// Returns an error when a process cannot be terminated. pub fn terminate_processes(pids: &[u32]) -> Result<(), WindowsSupportError> { let targets = pids .iter() .copied() .map(|pid| ProcessKillTarget { pid, start_time_unix: None, }) .collect::>(); terminate_processes_checked(&targets) } /// Terminates processes after re-checking PID identity metadata when available. /// /// # Errors /// /// Returns an error when a process start time no longer matches or termination fails. pub fn terminate_processes_checked( targets: &[ProcessKillTarget], ) -> Result<(), WindowsSupportError> { let snapshot = snapshot_processes(); let pids = validated_process_kill_pids(targets, &snapshot)?; terminate_process_ids(&pids) } fn validated_process_kill_pids( targets: &[ProcessKillTarget], processes: &[ProcessDescriptor], ) -> Result, WindowsSupportError> { let process_map = processes .iter() .map(|process| (process.pid, process)) .collect::>(); let mut unique = targets .iter() .copied() .filter(|target| target.pid != 0) .collect::>(); unique.sort_unstable(); unique.dedup(); let mut pids = Vec::with_capacity(unique.len()); for target in unique { if let Some(expected_start) = target.start_time_unix { let Some(process) = process_map.get(&target.pid) else { continue; }; if process.start_time_unix != expected_start { return Err(WindowsSupportError::Process(format!( "refusing to terminate pid {} because process start time changed from {} to {}", target.pid, expected_start, process.start_time_unix ))); } } pids.push(target.pid); } pids.sort_unstable(); pids.dedup(); Ok(pids) } fn terminate_process_ids(pids: &[u32]) -> Result<(), WindowsSupportError> { for pid in pids { let taskkill = system32_executable_from_root(&windows_root(), "taskkill.exe"); let output = Command::new(&taskkill) .args(["/PID", &pid.to_string(), "/F"]) .stdout(Stdio::null()) .stderr(Stdio::piped()) .output() .map_err(|error| { WindowsSupportError::Process(format!( "failed to invoke taskkill for pid {pid}: {error}" )) })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(WindowsSupportError::Process(format!( "taskkill failed for pid {pid} with status {}{}", output.status, if stderr.is_empty() { String::new() } else { format!(": {stderr}") } ))); } } Ok(()) } /// Sleeps for the requested duration. pub fn sleep_for(duration: Duration) { thread::sleep(duration); } fn is_path_like_name(name: &str) -> bool { matches!( name.to_ascii_uppercase().as_str(), "PATH" | "PSMODULEPATH" | "LIB" | "INCLUDE" ) } fn split_path_like(value: &str) -> Vec { value .split(';') .map(str::trim) .filter(|segment| !segment.is_empty()) .map(str::to_string) .collect() } const fn port_protocol_order(protocol: PortProtocol) -> u8 { match protocol { PortProtocol::Tcp => 0, PortProtocol::Udp => 1, } } fn fallback_lockers( path: &Path, process_map: &HashMap, ) -> Vec { let needle = path.display().to_string().to_ascii_lowercase(); let mut excluded = HashSet::new(); let mut current = Some(std::process::id()); while let Some(pid) = current { if !excluded.insert(pid) { break; } current = process_map.get(&pid).and_then(|process| process.parent_pid); } process_map .iter() .filter(|(pid, _)| !excluded.contains(pid)) .filter_map(|(pid, process)| { let command_line = process.command_line.join(" ").to_ascii_lowercase(); command_line.contains(&needle).then(|| LockerProcess { path: path.display().to_string(), pid: *pid, start_time_unix: Some(process.start_time_unix), app_name: process.image_name.clone(), service_name: String::new(), restartable: false, image_name: Some(process.image_name.clone()), command_line: process.command_line.clone(), }) }) .collect() } fn merge_locker_sources( mut restart_manager: Vec, handle_scan: Vec, ) -> Vec { let mut seen = restart_manager .iter() .map(|locker| locker.pid) .collect::>(); for locker in handle_scan { if let Some(existing) = restart_manager .iter_mut() .find(|existing| existing.pid == locker.pid) { if existing.start_time_unix.is_none() { existing.start_time_unix = locker.start_time_unix; } if existing.image_name.is_none() { existing.image_name.clone_from(&locker.image_name); } if existing.command_line.is_empty() { existing.command_line.clone_from(&locker.command_line); } } else if seen.insert(locker.pid) { restart_manager.push(locker); } } restart_manager.sort_by_key(|locker| locker.pid); restart_manager } fn resolve_fast_locker_query( restart_manager: Result, WindowsSupportError>, fallback: Vec, ) -> Result, WindowsSupportError> { match restart_manager { Ok(lockers) => Ok(lockers), Err(error) => { if fallback.is_empty() { Err(error) } else { Ok(fallback) } } } } fn resolve_deep_locker_query( restart_manager: Result, WindowsSupportError>, handle_scan: Vec, fallback: Vec, ) -> Result, WindowsSupportError> { match restart_manager { Ok(lockers) => { let lockers = merge_locker_sources(lockers, handle_scan); if lockers.is_empty() { Ok(fallback) } else { Ok(lockers) } } Err(error) => { if !handle_scan.is_empty() { Ok(merge_locker_sources(Vec::new(), handle_scan)) } else if !fallback.is_empty() { Ok(fallback) } else { Err(error) } } } } fn path_matches_target(candidate: &str, target: &str) -> bool { let candidate = normalize_windows_path(candidate); let target = normalize_windows_path(target); if candidate == target { return true; } candidate .strip_prefix(&target) .is_some_and(|tail| tail.starts_with('\\')) } fn normalize_windows_path(path: &str) -> String { let trimmed = path .trim() .trim_start_matches(r"\\?\") .strip_prefix(r"UNC\") .map_or_else( || path.trim().trim_start_matches(r"\\?\").to_string(), |unc| format!(r"\\{unc}"), ); trimmed .trim_end_matches(['\\', '/']) .replace('/', "\\") .to_ascii_lowercase() } #[cfg(not(windows))] fn scan_handle_lockers( _path: &Path, _process_map: &HashMap, ) -> Vec { Vec::new() } #[cfg(windows)] #[allow(unsafe_code)] fn scan_handle_lockers( path: &Path, process_map: &HashMap, ) -> Vec { let target = path.display().to_string(); let Some(handles) = system_handles() else { return Vec::new(); }; let current_process = unsafe { GetCurrentProcess() }; let mut process_handles = HashMap::::new(); let mut lockers = Vec::::new(); let mut seen = HashSet::::new(); for handle in &handles { let Some(locker) = locker_from_handle( handle, current_process, &target, process_map, &mut process_handles, &seen, ) else { continue; }; seen.insert(locker.pid); lockers.push(locker); } lockers.sort_by_key(|locker| locker.pid); lockers } #[cfg(windows)] const STATUS_INFO_LENGTH_MISMATCH: i32 = -1_073_741_820; #[cfg(windows)] const SYSTEM_EXTENDED_HANDLE_INFORMATION: i32 = 64; #[cfg(windows)] #[repr(C)] #[derive(Clone, Copy)] struct SystemHandleTableEntryInfoEx { object: *mut c_void, unique_process_id: usize, handle_value: usize, granted_access: u32, creator_back_trace_index: u16, object_type_index: u16, handle_attributes: u32, reserved: u32, } #[cfg(windows)] #[repr(C)] struct SystemHandleInformationEx { number_of_handles: usize, reserved: usize, handles: [SystemHandleTableEntryInfoEx; 1], } #[cfg(windows)] #[derive(Debug)] struct OwnedHandle(HANDLE); #[cfg(windows)] impl OwnedHandle { fn new(raw: HANDLE) -> Option { (!raw.is_null() && !std::ptr::eq(raw, INVALID_HANDLE_VALUE)).then_some(Self(raw)) } const fn raw(&self) -> HANDLE { self.0 } } #[cfg(windows)] #[allow(unsafe_code)] impl Drop for OwnedHandle { fn drop(&mut self) { unsafe { let _ = CloseHandle(self.0); } } } #[cfg(windows)] #[allow(unsafe_code)] fn system_handles() -> Option> { let mut buffer = vec![0_usize; bytes_to_words(1024 * 1024)]; loop { let mut return_length = 0_u32; let status = unsafe { NtQuerySystemInformation( SYSTEM_EXTENDED_HANDLE_INFORMATION, buffer.as_mut_ptr().cast::(), u32::try_from(buffer.len() * std::mem::size_of::()).ok()?, &raw mut return_length, ) }; if status == STATUS_INFO_LENGTH_MISMATCH { resize_handle_buffer(&mut buffer, return_length); continue; } if status < 0 { return None; } break; } let info = unsafe { &*(buffer.as_ptr().cast::()) }; let first = std::ptr::addr_of!(info.handles).cast::(); let handles = unsafe { std::slice::from_raw_parts(first, info.number_of_handles) }; Some(handles.to_vec()) } #[cfg(windows)] const fn bytes_to_words(bytes: usize) -> usize { bytes.div_ceil(std::mem::size_of::()) } #[cfg(windows)] fn resize_handle_buffer(buffer: &mut Vec, return_length: u32) { let requested = usize::try_from(return_length) .ok() .map(bytes_to_words) .filter(|words| *words > buffer.len()) .unwrap_or(buffer.len() * 2); buffer.resize(requested, 0); } #[cfg(windows)] #[allow(unsafe_code)] fn locker_from_handle( handle: &SystemHandleTableEntryInfoEx, current_process: HANDLE, target: &str, process_map: &HashMap, process_handles: &mut HashMap, seen: &HashSet, ) -> Option { let pid = u32::try_from(handle.unique_process_id).ok()?; if pid == 0 || pid == std::process::id() || seen.contains(&pid) { return None; } let process_handle = process_handle_for_pid(pid, process_handles)?; let duplicate = duplicate_handle(process_handle, handle.handle_value, current_process)?; if unsafe { GetFileType(duplicate.raw()) } != FILE_TYPE_DISK { return None; } let candidate = final_path_from_handle(duplicate.raw())?; if !path_matches_target(&candidate, target) { return None; } Some(locker_from_process(pid, target, process_map)) } #[cfg(windows)] #[allow(unsafe_code)] fn process_handle_for_pid( pid: u32, process_handles: &mut HashMap, ) -> Option { if let Some(process_handle) = process_handles.get(&pid) { return Some(process_handle.raw()); } let raw = unsafe { OpenProcess(PROCESS_DUP_HANDLE, FALSE, pid) }; let process_handle = OwnedHandle::new(raw)?; let raw = process_handle.raw(); process_handles.insert(pid, process_handle); Some(raw) } #[cfg(windows)] #[allow(unsafe_code)] fn duplicate_handle( process_handle: HANDLE, handle_value: usize, current_process: HANDLE, ) -> Option { let mut duplicate: HANDLE = null_mut(); let duplicated = unsafe { DuplicateHandle( process_handle, handle_value as HANDLE, current_process, &raw mut duplicate, 0, FALSE, DUPLICATE_SAME_ACCESS, ) }; if duplicated == FALSE { return None; } OwnedHandle::new(duplicate) } #[cfg(windows)] #[allow(unsafe_code)] fn final_path_from_handle(handle: HANDLE) -> Option { let mut buffer = vec![0_u16; 32_768]; let length = unsafe { GetFinalPathNameByHandleW( handle, buffer.as_mut_ptr(), u32::try_from(buffer.len()).ok()?, VOLUME_NAME_DOS, ) }; if length == 0 { return None; } let length = usize::try_from(length).ok()?; if length >= buffer.len() { return None; } Some(String::from_utf16_lossy(&buffer[..length])) } #[cfg(windows)] fn locker_from_process( pid: u32, target: &str, process_map: &HashMap, ) -> LockerProcess { let process = process_map.get(&pid); let image_name = process.map(|process| process.image_name.clone()); LockerProcess { path: target.to_string(), pid, start_time_unix: process.map(|process| process.start_time_unix), app_name: image_name.clone().unwrap_or_else(|| format!("pid:{pid}")), service_name: String::new(), restartable: false, image_name, command_line: process .map(|process| process.command_line.clone()) .unwrap_or_default(), } } fn invoke_pwsh_status(script: &str) -> Result<(), WindowsSupportError> { let pwsh = trusted_pwsh_executable()?; let status = Command::new(&pwsh) .arg("-NoProfile") .arg("-Command") .arg(script) .output() .map_err(|error| { WindowsSupportError::Process(format!("failed to launch {}: {error}", pwsh.display())) })?; if status.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&status.stderr); if let Some((action, code)) = parse_restart_manager_failure(&stderr) { Err(WindowsSupportError::RestartManager { action, code }) } else { Err(WindowsSupportError::Process(format!( "pwsh script failed with status {}: {}", status.status, stderr.trim() ))) } } } fn invoke_pwsh_json(script: &str) -> Result where T: serde::de::DeserializeOwned, { let pwsh = trusted_pwsh_executable()?; let output = Command::new(&pwsh) .arg("-NoProfile") .arg("-Command") .arg(script) .output() .map_err(|error| { WindowsSupportError::Process(format!("failed to launch {}: {error}", pwsh.display())) })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); if let Some((action, code)) = parse_restart_manager_failure(&stderr) { return Err(WindowsSupportError::RestartManager { action, code }); } return Err(WindowsSupportError::Process(format!( "pwsh script failed with status {}: {}", output.status, stderr.trim() ))); } let stdout = String::from_utf8_lossy(&output.stdout); let normalized = if stdout.trim().is_empty() { "[]" } else { stdout.trim() }; serde_json::from_str(normalized).map_err(|error| { WindowsSupportError::Process(format!("failed to parse pwsh JSON output: {error}")) }) } fn trusted_pwsh_executable() -> Result { trusted_pwsh_candidates() .into_iter() .find(|candidate| candidate.is_file()) .ok_or_else(|| { WindowsSupportError::Process( "failed to find trusted PowerShell executable in system locations".to_string(), ) }) } fn trusted_pwsh_candidates() -> Vec { let root = windows_root(); let program_files = env::var_os("ProgramW6432") .or_else(|| env::var_os("ProgramFiles")) .map(PathBuf::from); let program_files_x86 = env::var_os("ProgramFiles(x86)").map(PathBuf::from); trusted_pwsh_candidates_from_env( &root, program_files.as_deref(), program_files_x86.as_deref(), ) } fn trusted_pwsh_candidates_from_env( root: &Path, program_files: Option<&Path>, program_files_x86: Option<&Path>, ) -> Vec { let mut candidates = Vec::new(); if let Some(path) = program_files { candidates.push(path.join("PowerShell").join("7").join("pwsh.exe")); } if let Some(path) = program_files_x86 { let candidate = path.join("PowerShell").join("7").join("pwsh.exe"); if !candidates.iter().any(|existing| existing == &candidate) { candidates.push(candidate); } } candidates.push( root.join("System32") .join("WindowsPowerShell") .join("v1.0") .join("powershell.exe"), ); candidates } fn windows_root() -> PathBuf { env::var_os("SystemRoot") .or_else(|| env::var_os("WINDIR")) .map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from) } fn system32_executable_from_root(root: &Path, executable: &str) -> PathBuf { root.join("System32").join(executable) } fn parse_restart_manager_failure(stderr: &str) -> Option<(&'static str, u32)> { const PATTERNS: &[(&str, &str, &str)] = &[ ("query", "RmGetList failed:", "RmGetList failed:"), ( "query", "RmGetList preflight failed:", "RmGetList preflight failed:", ), ( "register", "RmRegisterResources failed:", "RmRegisterResources failed:", ), ( "start_session", "RmStartSession failed:", "RmStartSession failed:", ), ("shutdown", "RmShutdown failed:", "RmShutdown failed:"), ]; for (action, marker, trim_marker) in PATTERNS { if let Some(index) = stderr.find(marker) { let tail = &stderr[index + trim_marker.len()..]; let digits = tail .chars() .skip_while(|character| character.is_whitespace()) .take_while(char::is_ascii_digit) .collect::(); if let Ok(code) = digits.parse::() { return Some((action, code)); } } } None } const RESTART_MANAGER_SCRIPT: &str = r#" if (-not ('MercuryRestartManager' -as [type])) { Add-Type -TypeDefinition @' using System; using System.Collections.Generic; using System.Runtime.InteropServices; public static class MercuryRestartManager { [StructLayout(LayoutKind.Sequential)] public struct RM_UNIQUE_PROCESS { public int dwProcessId; public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct RM_PROCESS_INFO { public RM_UNIQUE_PROCESS Process; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string strAppName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string strServiceShortName; public int ApplicationType; public uint AppStatus; public uint TSSessionId; [MarshalAs(UnmanagedType.Bool)] public bool bRestartable; } [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] private static extern int RmStartSession(out uint sessionHandle, int sessionFlags, string sessionKey); [DllImport("rstrtmgr.dll")] private static extern int RmEndSession(uint sessionHandle); [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] private static extern int RmRegisterResources(uint sessionHandle, uint nFiles, string[] files, uint nApplications, IntPtr applications, uint nServices, string[] services); [DllImport("rstrtmgr.dll")] private static extern int RmGetList(uint sessionHandle, out uint procInfoNeeded, ref uint procInfo, [In, Out] RM_PROCESS_INFO[] processInfo, ref uint rebootReasons); [DllImport("rstrtmgr.dll")] private static extern int RmShutdown(uint sessionHandle, uint actionFlags, IntPtr callback); public static Dictionary[] Who(string path) { uint handle; string key = Guid.NewGuid().ToString("N"); int start = RmStartSession(out handle, 0, key); if (start != 0) { throw new InvalidOperationException("RmStartSession failed: " + start); } try { int register = RmRegisterResources(handle, 1, new[] { path }, 0, IntPtr.Zero, 0, null); if (register != 0) { throw new InvalidOperationException("RmRegisterResources failed: " + register); } uint needed = 0; uint count = 0; uint rebootReasons = 0; int first = RmGetList(handle, out needed, ref count, null, ref rebootReasons); if (first == 0) { return Array.Empty>(); } if (first != 234) { throw new InvalidOperationException("RmGetList preflight failed: " + first); } RM_PROCESS_INFO[] infos = new RM_PROCESS_INFO[needed]; count = needed; int second = RmGetList(handle, out needed, ref count, infos, ref rebootReasons); if (second != 0) { throw new InvalidOperationException("RmGetList failed: " + second); } var results = new List>(); for (int i = 0; i < count; i++) { results.Add(new Dictionary { ["pid"] = (uint)infos[i].Process.dwProcessId, ["app_name"] = infos[i].strAppName ?? "", ["service_name"] = infos[i].strServiceShortName ?? "", ["restartable"] = infos[i].bRestartable, }); } return results.ToArray(); } finally { RmEndSession(handle); } } public static void Shutdown(string[] paths, bool force) { uint handle; string key = Guid.NewGuid().ToString("N"); int start = RmStartSession(out handle, 0, key); if (start != 0) { throw new InvalidOperationException("RmStartSession failed: " + start); } try { int register = RmRegisterResources(handle, (uint)paths.Length, paths, 0, IntPtr.Zero, 0, null); if (register != 0) { throw new InvalidOperationException("RmRegisterResources failed: " + register); } int shutdown = RmShutdown(handle, force ? 1u : 0u, IntPtr.Zero); if (shutdown != 0 && shutdown != 121) { throw new InvalidOperationException("RmShutdown failed: " + shutdown); } } finally { RmEndSession(handle); } } } '@ } "#; fn format_pwsh_array(paths: &[PathBuf]) -> String { let mut output = String::from("@("); for (index, path) in paths.iter().enumerate() { if index > 0 { output.push_str(", "); } let _ = write!( output, "'{}'", escape_pwsh_single_quoted(&path.display().to_string()) ); } output.push(')'); output } fn escape_pwsh_single_quoted(value: &str) -> String { value.replace('\'', "''") } #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] struct PwshLockerInfo { pid: u32, app_name: String, service_name: String, restartable: bool, } impl From for CliError { fn from(value: WindowsSupportError) -> Self { Self::runtime(value.to_string()) } } #[cfg(test)] mod tests { use super::*; use std::fs; use std::net::{TcpListener, UdpSocket}; #[test] fn environment_diff_tracks_changes_and_path_segments() { let before = EnvironmentSnapshot { values: BTreeMap::from([ ("PATH".to_string(), r"C:\A;C:\B".to_string()), ("KEEP".to_string(), "old".to_string()), ]), }; let after = EnvironmentSnapshot { values: BTreeMap::from([ ("PATH".to_string(), r"C:\B;C:\C".to_string()), ("KEEP".to_string(), "new".to_string()), ("ADD".to_string(), "x".to_string()), ]), }; let diff = diff_environments(&before, &after); assert_eq!(diff.added.len(), 1); assert_eq!(diff.removed.len(), 0); assert_eq!(diff.changed.len(), 2); assert_eq!(diff.path_like_changes[0].added_segments, vec![r"C:\C"]); assert_eq!(diff.path_like_changes[0].removed_segments, vec![r"C:\A"]); } #[test] fn split_path_like_discards_empty_segments() { assert_eq!(split_path_like(r"C:\A;;C:\B;"), vec![r"C:\A", r"C:\B"]); assert!(is_path_like_name("PATH")); assert!(!is_path_like_name("HOME")); } #[test] fn capture_read_and_path_helpers_cover_snapshot_io() { let captured = capture_environment(); assert!(captured.values.contains_key("PATH") || !captured.values.is_empty()); let file = std::env::temp_dir().join(format!( "windowsupport-env-{}.txt", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("epoch") .as_nanos() )); fs::write(&file, "ONE=1\nTWO=2\n").expect("env file"); let loaded = read_environment_file(&file).expect("read env"); assert_eq!(loaded.values.get("ONE"), Some(&"1".to_string())); assert_eq!( format_pwsh_array(&[PathBuf::from("C:\\Tool"), PathBuf::from("C:\\O'Hare")]), "@('C:\\Tool', 'C:\\O''Hare')" ); assert_eq!(escape_pwsh_single_quoted("a'b"), "a''b"); let _ = fs::remove_file(file); } #[test] fn trusted_helper_paths_do_not_use_path_search() { let root = Path::new(r"C:\Windows"); assert_eq!( system32_executable_from_root(root, "taskkill.exe"), root.join("System32").join("taskkill.exe") ); assert_eq!( trusted_pwsh_candidates_from_env( root, Some(Path::new(r"C:\Program Files")), Some(Path::new(r"C:\Program Files (x86)")), ), vec![ PathBuf::from(r"C:\Program Files\PowerShell\7\pwsh.exe"), PathBuf::from(r"C:\Program Files (x86)\PowerShell\7\pwsh.exe"), PathBuf::from(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"), ] ); } #[test] fn process_and_pwsh_helpers_cover_success_paths() { let processes = snapshot_processes(); assert!(!processes.is_empty()); assert!( processes .iter() .any(|process| process.pid == std::process::id()) ); invoke_pwsh_status("$value = 1").expect("status"); let value = invoke_pwsh_json::("@{ ok = $true } | ConvertTo-Json -Compress") .expect("json"); assert_eq!( value.get("ok").and_then(serde_json::Value::as_bool), Some(true) ); } #[test] fn locker_operations_cover_empty_and_unlocked_paths() { graceful_release(&[]).expect("empty graceful release"); let file = std::env::temp_dir().join(format!( "windowsupport-lock-{}.txt", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("epoch") .as_nanos() )); fs::write(&file, "free").expect("fixture"); let lockers = query_file_lockers(&file, LockerQueryMode::Fast).expect("query lockers"); assert!( lockers.is_empty() || lockers .iter() .all(|locker| locker.path == file.display().to_string()) ); force_release_with_locker_query(std::slice::from_ref(&file), |_, _| Ok(Vec::new())) .expect("force release on unlocked file"); let _ = fs::remove_file(file); } #[test] fn locker_source_merge_deduplicates_and_enriches_restart_manager_results() { let restart_manager = vec![LockerProcess { path: r"C:\Temp\locked.txt".to_string(), pid: 42, start_time_unix: Some(1_000), app_name: "RestartManagerApp".to_string(), service_name: String::new(), restartable: true, image_name: None, command_line: Vec::new(), }]; let handle_scan = vec![ LockerProcess { path: r"C:\Temp\locked.txt".to_string(), pid: 42, start_time_unix: Some(1_000), app_name: "locker.exe".to_string(), service_name: String::new(), restartable: false, image_name: Some("locker.exe".to_string()), command_line: vec!["locker.exe C:\\Temp\\locked.txt".to_string()], }, LockerProcess { path: r"C:\Temp\locked.txt".to_string(), pid: 7, start_time_unix: Some(2_000), app_name: "extra.exe".to_string(), service_name: String::new(), restartable: false, image_name: Some("extra.exe".to_string()), command_line: vec!["extra.exe".to_string()], }, ]; let merged = merge_locker_sources(restart_manager, handle_scan); assert_eq!(merged.len(), 2); let restart_manager_entry = merged .iter() .find(|locker| locker.pid == 42) .expect("restart manager entry remains"); assert_eq!(restart_manager_entry.app_name, "RestartManagerApp"); assert!(restart_manager_entry.restartable); assert_eq!( restart_manager_entry.image_name.as_deref(), Some("locker.exe") ); assert_eq!( restart_manager_entry.command_line, vec!["locker.exe C:\\Temp\\locked.txt".to_string()] ); assert!(merged.iter().any(|locker| locker.pid == 7)); } #[test] fn process_kill_targets_reject_pid_reuse_by_start_time() { let processes = vec![ProcessDescriptor { pid: 42, parent_pid: None, image_name: "server.exe".to_string(), exe: None, command_line: vec!["server.exe".to_string()], start_time_unix: 2_000, run_time_seconds: 1, }]; let error = validated_process_kill_pids( &[ProcessKillTarget { pid: 42, start_time_unix: Some(1_000), }], &processes, ) .expect_err("changed start time should be rejected"); assert!(error.to_string().contains("start time changed")); let stale_exit = validated_process_kill_pids( &[ProcessKillTarget { pid: 43, start_time_unix: Some(1_000), }], &processes, ) .expect("already exited pid should be skipped"); assert!(stale_exit.is_empty()); let unchecked = validated_process_kill_pids( &[ProcessKillTarget { pid: 44, start_time_unix: None, }], &processes, ) .expect("bare pid remains supported"); assert_eq!(unchecked, vec![44]); } #[test] fn fast_locker_query_uses_fallback_when_restart_manager_fails() { let restart_manager = Err(WindowsSupportError::RestartManager { action: "query", code: 5, }); let fallback = vec![LockerProcess { path: r"C:\Users\example\Documents\locked.txt".to_string(), pid: 77, start_time_unix: Some(3_000), app_name: "pwsh.exe".to_string(), service_name: String::new(), restartable: false, image_name: Some("pwsh.exe".to_string()), command_line: vec!["pwsh.exe".to_string(), "locked.txt".to_string()], }]; let lockers = resolve_fast_locker_query(restart_manager, fallback).expect("fast fallback"); assert_eq!(lockers.len(), 1); assert_eq!(lockers[0].pid, 77); } #[test] fn deep_locker_query_uses_handle_scan_when_restart_manager_fails() { let restart_manager = Err(WindowsSupportError::RestartManager { action: "query", code: 5, }); let handle_scan = vec![LockerProcess { path: r"C:\Users\example\Documents\ExampleProject".to_string(), pid: 4242, start_time_unix: Some(4_000), app_name: "node_repl.exe".to_string(), service_name: String::new(), restartable: false, image_name: Some("node_repl.exe".to_string()), command_line: vec!["node_repl.exe".to_string()], }]; let lockers = resolve_deep_locker_query(restart_manager, handle_scan, Vec::new()) .expect("handle fallback"); assert_eq!(lockers.len(), 1); assert_eq!(lockers[0].app_name, "node_repl.exe"); } #[test] fn fallback_lockers_reads_existing_process_snapshot_command_lines() { let path = PathBuf::from(r"C:\Temp\locked.txt"); let mut process_map = HashMap::new(); process_map.insert( 42, ProcessDescriptor { pid: 42, parent_pid: None, image_name: "pwsh.exe".to_string(), exe: None, command_line: vec![ "pwsh.exe".to_string(), "-File".to_string(), r"C:\Temp\locker.ps1".to_string(), r"C:\Temp\locked.txt".to_string(), ], start_time_unix: 0, run_time_seconds: 0, }, ); let lockers = fallback_lockers(&path, &process_map); assert_eq!(lockers.len(), 1); assert_eq!(lockers[0].pid, 42); assert_eq!(lockers[0].command_line.len(), 4); } #[test] fn locker_path_matching_is_case_insensitive_and_directory_aware() { assert!(path_matches_target( r"C:\Temp\Locked.txt", r"c:\temp\locked.txt" )); assert!(path_matches_target(r"C:\Temp\Child\locked.txt", r"c:\temp")); assert!(!path_matches_target(r"C:\Template\locked.txt", r"c:\temp")); } #[test] fn port_snapshot_reports_tcp_and_udp_owners() { let tcp = TcpListener::bind("127.0.0.1:0").expect("tcp"); let udp = UdpSocket::bind("127.0.0.1:0").expect("udp"); let tcp_port = tcp.local_addr().expect("tcp addr").port(); let udp_port = udp.local_addr().expect("udp addr").port(); let owners = snapshot_port_owners().expect("port owners"); let tcp_owner = owners .iter() .find(|owner| owner.port == tcp_port && owner.protocol == PortProtocol::Tcp) .expect("tcp owner"); let udp_owner = owners .iter() .find(|owner| owner.port == udp_port && owner.protocol == PortProtocol::Udp) .expect("udp owner"); assert_eq!(tcp_owner.pid, std::process::id()); assert_eq!(udp_owner.pid, std::process::id()); assert_eq!(tcp_owner.state.as_deref(), Some("LISTEN")); assert!(tcp_owner.local_address.contains("127.0.0.1")); assert!(udp_owner.local_address.contains("127.0.0.1")); } }