1701 lines
54 KiB
Rust
1701 lines
54 KiB
Rust
#![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<String, String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// Segments removed from the path-like variable.
|
|
pub removed_segments: Vec<String>,
|
|
}
|
|
|
|
/// Stable environment diff shape used by `envdiff`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct EnvironmentDiff {
|
|
/// Variables only present after execution.
|
|
pub added: Vec<EnvAdded>,
|
|
/// Variables removed by execution.
|
|
pub removed: Vec<EnvRemoved>,
|
|
/// Variables present in both snapshots with changed values.
|
|
pub changed: Vec<EnvChanged>,
|
|
/// Path-like segment additions/removals for common variables.
|
|
pub path_like_changes: Vec<PathLikeChange>,
|
|
}
|
|
|
|
/// 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<u32>,
|
|
/// Executable image name.
|
|
pub image_name: String,
|
|
/// Full executable path when known.
|
|
pub exe: Option<String>,
|
|
/// Full command line.
|
|
pub command_line: Vec<String>,
|
|
/// 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<u64>,
|
|
}
|
|
|
|
/// 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<u64>,
|
|
/// 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<String>,
|
|
/// Full process command line when known.
|
|
pub command_line: Vec<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// Owning process identifier.
|
|
pub pid: u32,
|
|
/// Process start time captured with the PID, when known.
|
|
pub start_time_unix: Option<u64>,
|
|
/// Executable image name when known.
|
|
pub image_name: Option<String>,
|
|
/// Service label when known.
|
|
pub service_name: Option<String>,
|
|
/// Full process command line when known.
|
|
pub command_line: Vec<String>,
|
|
}
|
|
|
|
/// 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::<BTreeMap<_, _>>();
|
|
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<EnvironmentSnapshot, WindowsSupportError> {
|
|
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::<BTreeMap<_, _>>();
|
|
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::<BTreeSet<_>>();
|
|
|
|
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<ProcessDescriptor> {
|
|
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::<Vec<_>>();
|
|
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<Vec<PortOwner>, WindowsSupportError> {
|
|
let process_map = snapshot_processes()
|
|
.into_iter()
|
|
.map(|process| (process.pid, process))
|
|
.collect::<HashMap<_, _>>();
|
|
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<Vec<LockerProcess>, WindowsSupportError> {
|
|
let process_map = snapshot_processes()
|
|
.into_iter()
|
|
.map(|process| (process.pid, process))
|
|
.collect::<HashMap<_, _>>();
|
|
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::<serde_json::Value>(&script).map(|value| {
|
|
let infos = match value {
|
|
serde_json::Value::Array(items) => items
|
|
.into_iter()
|
|
.filter_map(|item| serde_json::from_value::<PwshLockerInfo>(item).ok())
|
|
.collect::<Vec<_>>(),
|
|
serde_json::Value::Object(_) => serde_json::from_value::<PwshLockerInfo>(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::<Vec<_>>()
|
|
});
|
|
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<Q>(
|
|
paths: &[PathBuf],
|
|
locker_query: Q,
|
|
) -> Result<(), WindowsSupportError>
|
|
where
|
|
Q: Fn(&Path, LockerQueryMode) -> Result<Vec<LockerProcess>, WindowsSupportError>,
|
|
{
|
|
let blockers = paths
|
|
.iter()
|
|
.map(|path| locker_query(path, LockerQueryMode::Fast))
|
|
.collect::<Result<Vec<_>, _>>()?
|
|
.into_iter()
|
|
.flatten()
|
|
.map(|locker| ProcessKillTarget {
|
|
pid: locker.pid,
|
|
start_time_unix: locker.start_time_unix,
|
|
})
|
|
.collect::<HashSet<_>>();
|
|
let blocker_list = blockers.into_iter().collect::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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<Vec<u32>, WindowsSupportError> {
|
|
let process_map = processes
|
|
.iter()
|
|
.map(|process| (process.pid, process))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut unique = targets
|
|
.iter()
|
|
.copied()
|
|
.filter(|target| target.pid != 0)
|
|
.collect::<Vec<_>>();
|
|
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<String> {
|
|
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<u32, ProcessDescriptor>,
|
|
) -> Vec<LockerProcess> {
|
|
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<LockerProcess>,
|
|
handle_scan: Vec<LockerProcess>,
|
|
) -> Vec<LockerProcess> {
|
|
let mut seen = restart_manager
|
|
.iter()
|
|
.map(|locker| locker.pid)
|
|
.collect::<HashSet<_>>();
|
|
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<Vec<LockerProcess>, WindowsSupportError>,
|
|
fallback: Vec<LockerProcess>,
|
|
) -> Result<Vec<LockerProcess>, 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<Vec<LockerProcess>, WindowsSupportError>,
|
|
handle_scan: Vec<LockerProcess>,
|
|
fallback: Vec<LockerProcess>,
|
|
) -> Result<Vec<LockerProcess>, 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<u32, ProcessDescriptor>,
|
|
) -> Vec<LockerProcess> {
|
|
Vec::new()
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[allow(unsafe_code)]
|
|
fn scan_handle_lockers(
|
|
path: &Path,
|
|
process_map: &HashMap<u32, ProcessDescriptor>,
|
|
) -> Vec<LockerProcess> {
|
|
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::<u32, OwnedHandle>::new();
|
|
let mut lockers = Vec::<LockerProcess>::new();
|
|
let mut seen = HashSet::<u32>::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<Self> {
|
|
(!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<Vec<SystemHandleTableEntryInfoEx>> {
|
|
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::<c_void>(),
|
|
u32::try_from(buffer.len() * std::mem::size_of::<usize>()).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::<SystemHandleInformationEx>()) };
|
|
let first = std::ptr::addr_of!(info.handles).cast::<SystemHandleTableEntryInfoEx>();
|
|
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::<usize>())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn resize_handle_buffer(buffer: &mut Vec<usize>, 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<u32, ProcessDescriptor>,
|
|
process_handles: &mut HashMap<u32, OwnedHandle>,
|
|
seen: &HashSet<u32>,
|
|
) -> Option<LockerProcess> {
|
|
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<u32, OwnedHandle>,
|
|
) -> Option<HANDLE> {
|
|
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<OwnedHandle> {
|
|
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<String> {
|
|
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<u32, ProcessDescriptor>,
|
|
) -> 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<T>(script: &str) -> Result<T, WindowsSupportError>
|
|
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<PathBuf, WindowsSupportError> {
|
|
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<PathBuf> {
|
|
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<PathBuf> {
|
|
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::<String>();
|
|
if let Ok(code) = digits.parse::<u32>() {
|
|
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<string, object>[] 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<Dictionary<string, object>>();
|
|
}
|
|
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<Dictionary<string, object>>();
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
results.Add(new Dictionary<string, object>
|
|
{
|
|
["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<WindowsSupportError> 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::<serde_json::Value>("@{ 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"));
|
|
}
|
|
}
|