Files
MercuryToolbox/crates/windowsupport/src/lib.rs
T

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"));
}
}