Agent-Logs-Url: https://github.com/JMR-dev/covenant-setup/sessions/351376c1-0b93-4176-9cab-35d4cc72957a Co-authored-by: JMR-dev <51939451+JMR-dev@users.noreply.github.com>
950 lines
32 KiB
Rust
950 lines
32 KiB
Rust
use crate::{AppError, Logger, RegistryRoot};
|
|
use serde_json::json;
|
|
use std::ffi::{OsStr, c_void};
|
|
use std::fs;
|
|
use std::iter;
|
|
use std::os::windows::ffi::OsStrExt;
|
|
use std::path::{Path, PathBuf};
|
|
use windows::Win32::Foundation::{
|
|
CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_MORE_DATA, ERROR_SUCCESS, HANDLE, HWND, WIN32_ERROR,
|
|
};
|
|
use windows::Win32::Security::{GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation};
|
|
use windows::Win32::Storage::FileSystem::{
|
|
CopyFile2, CreateDirectoryW, DeleteFileW, MOVE_FILE_FLAGS, MOVEFILE_DELAY_UNTIL_REBOOT,
|
|
MoveFileExW, RemoveDirectoryW,
|
|
};
|
|
use windows::Win32::System::Com::{
|
|
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx,
|
|
CoTaskMemFree, CoUninitialize, IPersistFile,
|
|
};
|
|
use windows::Win32::System::Registry::{
|
|
HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_SET_VALUE, KEY_WOW64_64KEY,
|
|
REG_OPEN_CREATE_OPTIONS, REG_OPTION_NON_VOLATILE, REG_SAM_FLAGS, REG_SZ, REG_VALUE_TYPE,
|
|
RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegSetValueExW,
|
|
};
|
|
use windows::Win32::System::RestartManager::{
|
|
RM_PROCESS_INFO, RmEndSession, RmGetList, RmRegisterResources, RmStartSession,
|
|
};
|
|
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
|
use windows::Win32::UI::Shell::{
|
|
FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64, FOLDERID_ProgramFilesX86,
|
|
FOLDERID_Windows, IShellLinkW, KNOWN_FOLDER_FLAG, SHGetKnownFolderPath, ShellExecuteW,
|
|
ShellLink,
|
|
};
|
|
use windows::Win32::UI::WindowsAndMessaging::SW_SHOW;
|
|
use windows::core::{Interface, PCWSTR, PWSTR, w};
|
|
|
|
pub struct PathResolver {
|
|
pub program_files_x64: PathBuf,
|
|
pub local_app_data: PathBuf,
|
|
pub desktop: PathBuf,
|
|
admin_roots: Vec<String>,
|
|
}
|
|
|
|
impl PathResolver {
|
|
pub fn new(logger: &Logger) -> Result<Self, AppError> {
|
|
let program_files_x64 = known_folder(&FOLDERID_ProgramFilesX64, logger)?;
|
|
let program_files_x86 = known_folder(&FOLDERID_ProgramFilesX86, logger)?;
|
|
let windows_dir = known_folder(&FOLDERID_Windows, logger)?;
|
|
let admin_roots =
|
|
build_admin_roots(&[&program_files_x64, &program_files_x86, &windows_dir]);
|
|
Ok(Self {
|
|
program_files_x64,
|
|
local_app_data: known_folder(&FOLDERID_LocalAppData, logger)?,
|
|
desktop: known_folder(&FOLDERID_Desktop, logger)?,
|
|
admin_roots,
|
|
})
|
|
}
|
|
|
|
pub fn resolve(&self, input: &str) -> PathBuf {
|
|
PathBuf::from(
|
|
input
|
|
.replace(
|
|
"{ProgramFilesX64}",
|
|
&self.program_files_x64.to_string_lossy(),
|
|
)
|
|
.replace("{LocalAppData}", &self.local_app_data.to_string_lossy())
|
|
.replace("{Desktop}", &self.desktop.to_string_lossy()),
|
|
)
|
|
}
|
|
|
|
pub fn requires_admin(&self, path: &Path) -> bool {
|
|
let candidate = normalize_for_admin_match(path);
|
|
self.admin_roots
|
|
.iter()
|
|
.any(|root| candidate == *root || candidate.starts_with(&format!("{root}\\")))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn with_roots_for_test(roots: Vec<PathBuf>) -> Self {
|
|
let admin_roots = build_admin_roots(&roots.iter().collect::<Vec<_>>());
|
|
Self {
|
|
program_files_x64: PathBuf::new(),
|
|
local_app_data: PathBuf::new(),
|
|
desktop: PathBuf::new(),
|
|
admin_roots,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_admin_roots(roots: &[&PathBuf]) -> Vec<String> {
|
|
roots
|
|
.iter()
|
|
.map(|p| {
|
|
let lower = p.to_string_lossy().replace('/', "\\").to_ascii_lowercase();
|
|
lower.trim_end_matches('\\').to_string()
|
|
})
|
|
.filter(|root| !root.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn normalize_for_admin_match(path: &Path) -> String {
|
|
let lower = path
|
|
.to_string_lossy()
|
|
.replace('/', "\\")
|
|
.to_ascii_lowercase();
|
|
lower.trim_end_matches('\\').to_string()
|
|
}
|
|
|
|
// Encodes a single argument for a Windows command line that will be parsed by
|
|
// CommandLineToArgvW (which is what ShellExecuteW's lpParameters feeds into,
|
|
// and what every standard Win32 process startup uses to populate argv).
|
|
//
|
|
// Rules: quote if empty or contains space/tab/quote; inside quotes, escape `"`
|
|
// as `\"` and double any run of backslashes that immediately precedes a quote
|
|
// or the closing quote.
|
|
fn quote_command_line_arg(arg: &str) -> String {
|
|
let needs_quoting = arg.is_empty() || arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
|
|
if !needs_quoting {
|
|
return arg.to_string();
|
|
}
|
|
|
|
let mut out = String::with_capacity(arg.len() + 2);
|
|
out.push('"');
|
|
let mut backslashes = 0usize;
|
|
for c in arg.chars() {
|
|
match c {
|
|
'\\' => backslashes += 1,
|
|
'"' => {
|
|
for _ in 0..(backslashes * 2 + 1) {
|
|
out.push('\\');
|
|
}
|
|
out.push('"');
|
|
backslashes = 0;
|
|
}
|
|
_ => {
|
|
for _ in 0..backslashes {
|
|
out.push('\\');
|
|
}
|
|
out.push(c);
|
|
backslashes = 0;
|
|
}
|
|
}
|
|
}
|
|
for _ in 0..(backslashes * 2) {
|
|
out.push('\\');
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|
|
|
|
pub fn is_elevated(logger: &Logger) -> Result<bool, AppError> {
|
|
let mut token = HANDLE::default();
|
|
logger.unsafe_enter("OpenProcessToken", json!({}));
|
|
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)? };
|
|
logger.unsafe_exit("OpenProcessToken", json!({"opened": !token.is_invalid()}));
|
|
|
|
let mut elevation = TOKEN_ELEVATION::default();
|
|
let mut returned = 0u32;
|
|
logger.unsafe_enter("GetTokenInformation", json!({"class":"TokenElevation"}));
|
|
let info_result = unsafe {
|
|
GetTokenInformation(
|
|
token,
|
|
TokenElevation,
|
|
Some((&mut elevation as *mut TOKEN_ELEVATION).cast::<c_void>()),
|
|
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
|
|
&mut returned,
|
|
)
|
|
};
|
|
logger.unsafe_exit("GetTokenInformation", json!({"returned": returned}));
|
|
close_handle(token, logger)?;
|
|
info_result?;
|
|
if returned < std::mem::size_of::<TOKEN_ELEVATION>() as u32 {
|
|
return Err(AppError::Message("Short TOKEN_ELEVATION payload".into()));
|
|
}
|
|
Ok(elevation.TokenIsElevated != 0)
|
|
}
|
|
|
|
pub fn relaunch_as_admin(logger: &Logger) -> Result<(), AppError> {
|
|
let exe = std::env::current_exe()?;
|
|
let params = std::env::args()
|
|
.skip(1)
|
|
.map(|arg| quote_command_line_arg(&arg))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
logger.unsafe_enter(
|
|
"ShellExecuteW",
|
|
json!({"verb":"runas","exe":exe,"params":params}),
|
|
);
|
|
let result = unsafe {
|
|
ShellExecuteW(
|
|
Some(HWND::default()),
|
|
w!("runas"),
|
|
PCWSTR(Utf16Arg::from_path(&exe).as_ptr()),
|
|
PCWSTR(Utf16Arg::from_str(¶ms).as_ptr()),
|
|
PCWSTR::null(),
|
|
SW_SHOW,
|
|
)
|
|
};
|
|
let code = result.0 as isize;
|
|
logger.unsafe_exit("ShellExecuteW", json!({"hinstance": code}));
|
|
if code <= 32 {
|
|
return Err(AppError::Message(format!("ShellExecuteW failed: {code}")));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn create_directory_recursive(path: &Path, logger: &Logger) -> Result<(), AppError> {
|
|
if path.as_os_str().is_empty() || path.exists() {
|
|
return Ok(());
|
|
}
|
|
if let Some(parent) = path.parent() {
|
|
if parent != path {
|
|
create_directory_recursive(parent, logger)?;
|
|
}
|
|
}
|
|
logger.unsafe_enter("CreateDirectoryW", json!({"path": path}));
|
|
let result = unsafe { CreateDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr()), None) };
|
|
logger.unsafe_exit("CreateDirectoryW", json!({"ok": result.is_ok()}));
|
|
if let Err(err) = result {
|
|
if !path.exists() {
|
|
return Err(err.into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn copy_file(source: &Path, destination: &Path, logger: &Logger) -> Result<(), AppError> {
|
|
let source_w = Utf16Arg::from_path(source);
|
|
let dest_w = Utf16Arg::from_path(destination);
|
|
logger.unsafe_enter(
|
|
"CopyFile2",
|
|
json!({"source":source,"destination":destination}),
|
|
);
|
|
let result = unsafe { CopyFile2(PCWSTR(source_w.as_ptr()), PCWSTR(dest_w.as_ptr()), None) };
|
|
logger.unsafe_exit("CopyFile2", json!({"ok": result.is_ok()}));
|
|
result?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn remove_directory_if_exists(path: &Path, logger: &Logger) -> Result<(), AppError> {
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
logger.unsafe_enter("RemoveDirectoryW", json!({"path": path}));
|
|
let result = unsafe { RemoveDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) };
|
|
logger.unsafe_exit("RemoveDirectoryW", json!({"ok": result.is_ok()}));
|
|
if let Err(err) = result {
|
|
if path.is_dir() && fs::read_dir(path)?.next().is_some() {
|
|
logger.info(
|
|
"remove_directory_deferred",
|
|
json!({"path":path,"reason":"not_empty"}),
|
|
);
|
|
return Ok(());
|
|
}
|
|
if path.exists() {
|
|
return Err(err.into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn remove_file_with_fallback(path: &Path, logger: &Logger) -> Result<(), AppError> {
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
logger.unsafe_enter("DeleteFileW", json!({"path": path}));
|
|
let delete_result = unsafe { DeleteFileW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) };
|
|
logger.unsafe_exit("DeleteFileW", json!({"ok": delete_result.is_ok()}));
|
|
if delete_result.is_ok() {
|
|
return Ok(());
|
|
}
|
|
let pids = get_locking_processes(path, logger).unwrap_or_default();
|
|
if !pids.is_empty() {
|
|
logger.info("locked_file", json!({"path":path,"processes":pids}));
|
|
}
|
|
logger.unsafe_enter("MoveFileExW", json!({"path": path}));
|
|
let move_result = unsafe {
|
|
MoveFileExW(
|
|
PCWSTR(Utf16Arg::from_path(path).as_ptr()),
|
|
PCWSTR::null(),
|
|
MOVE_FILE_FLAGS(MOVEFILE_DELAY_UNTIL_REBOOT.0),
|
|
)
|
|
};
|
|
logger.unsafe_exit("MoveFileExW", json!({"ok": move_result.is_ok()}));
|
|
move_result?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_registry_string(
|
|
root: RegistryRoot,
|
|
subkey: &str,
|
|
name: &str,
|
|
value: &str,
|
|
logger: &Logger,
|
|
) -> Result<(), AppError> {
|
|
let mut key = HKEY::default();
|
|
logger.unsafe_enter("RegCreateKeyExW", json!({"root":root,"subkey":subkey}));
|
|
let create_result = unsafe {
|
|
RegCreateKeyExW(
|
|
root_hkey(root),
|
|
PCWSTR(Utf16Arg::from_str(subkey).as_ptr()),
|
|
Some(0),
|
|
PWSTR::null(),
|
|
REG_OPEN_CREATE_OPTIONS(REG_OPTION_NON_VOLATILE.0),
|
|
REG_SAM_FLAGS(KEY_SET_VALUE.0 | KEY_WOW64_64KEY.0),
|
|
None,
|
|
&mut key,
|
|
None,
|
|
)
|
|
};
|
|
logger.unsafe_exit("RegCreateKeyExW", json!({"status": create_result.0}));
|
|
win32_ok(create_result, "RegCreateKeyExW")?;
|
|
|
|
let utf16 = Utf16Arg::from_str(value);
|
|
logger.unsafe_enter("RegSetValueExW", json!({"name":name}));
|
|
let set_result = unsafe {
|
|
RegSetValueExW(
|
|
key,
|
|
PCWSTR(Utf16Arg::from_str(name).as_ptr()),
|
|
Some(0),
|
|
REG_VALUE_TYPE(REG_SZ.0),
|
|
Some(utf16.as_bytes()),
|
|
)
|
|
};
|
|
logger.unsafe_exit("RegSetValueExW", json!({"status": set_result.0}));
|
|
let close_result = close_registry_key(key, logger);
|
|
win32_ok(set_result, "RegSetValueExW")?;
|
|
close_result?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn delete_registry_tree(
|
|
root: RegistryRoot,
|
|
subkey: &str,
|
|
logger: &Logger,
|
|
) -> Result<(), AppError> {
|
|
logger.unsafe_enter("RegDeleteTreeW", json!({"root":root,"subkey":subkey}));
|
|
let result =
|
|
unsafe { RegDeleteTreeW(root_hkey(root), PCWSTR(Utf16Arg::from_str(subkey).as_ptr())) };
|
|
logger.unsafe_exit("RegDeleteTreeW", json!({"status": result.0}));
|
|
if result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND {
|
|
return Ok(());
|
|
}
|
|
win32_ok(result, "RegDeleteTreeW")
|
|
}
|
|
|
|
pub fn create_shortcut(
|
|
shortcut_path: &Path,
|
|
target: &Path,
|
|
arguments: Option<&str>,
|
|
working_directory: Option<&Path>,
|
|
description: Option<&str>,
|
|
logger: &Logger,
|
|
) -> Result<(), AppError> {
|
|
logger.unsafe_enter("CoInitializeEx", json!({}));
|
|
unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()? };
|
|
logger.unsafe_exit("CoInitializeEx", json!({"ok":true}));
|
|
let result = (|| -> Result<(), AppError> {
|
|
logger.unsafe_enter("CoCreateInstance", json!({"class":"ShellLink"}));
|
|
let link: IShellLinkW =
|
|
unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)? };
|
|
logger.unsafe_exit("CoCreateInstance", json!({"ok":true}));
|
|
|
|
logger.unsafe_enter("IShellLinkW::SetPath", json!({"target": target}));
|
|
unsafe { link.SetPath(PCWSTR(Utf16Arg::from_path(target).as_ptr()))? };
|
|
logger.unsafe_exit("IShellLinkW::SetPath", json!({"ok":true}));
|
|
|
|
if let Some(arguments) = arguments {
|
|
logger.unsafe_enter("IShellLinkW::SetArguments", json!({"arguments":arguments}));
|
|
unsafe { link.SetArguments(PCWSTR(Utf16Arg::from_str(arguments).as_ptr()))? };
|
|
logger.unsafe_exit("IShellLinkW::SetArguments", json!({"ok":true}));
|
|
}
|
|
if let Some(working_directory) = working_directory {
|
|
logger.unsafe_enter(
|
|
"IShellLinkW::SetWorkingDirectory",
|
|
json!({"working_directory":working_directory}),
|
|
);
|
|
unsafe {
|
|
link.SetWorkingDirectory(PCWSTR(Utf16Arg::from_path(working_directory).as_ptr()))?
|
|
};
|
|
logger.unsafe_exit("IShellLinkW::SetWorkingDirectory", json!({"ok":true}));
|
|
}
|
|
if let Some(description) = description {
|
|
logger.unsafe_enter(
|
|
"IShellLinkW::SetDescription",
|
|
json!({"description":description}),
|
|
);
|
|
unsafe { link.SetDescription(PCWSTR(Utf16Arg::from_str(description).as_ptr()))? };
|
|
logger.unsafe_exit("IShellLinkW::SetDescription", json!({"ok":true}));
|
|
}
|
|
|
|
logger.unsafe_enter("Interface::cast<IPersistFile>", json!({}));
|
|
let persist: IPersistFile = link.cast()?;
|
|
logger.unsafe_exit("Interface::cast<IPersistFile>", json!({"ok":true}));
|
|
logger.unsafe_enter("IPersistFile::Save", json!({"path":shortcut_path}));
|
|
unsafe { persist.Save(PCWSTR(Utf16Arg::from_path(shortcut_path).as_ptr()), true)? };
|
|
logger.unsafe_exit("IPersistFile::Save", json!({"ok":true}));
|
|
Ok(())
|
|
})();
|
|
logger.unsafe_enter("CoUninitialize", json!({}));
|
|
unsafe { CoUninitialize() };
|
|
logger.unsafe_exit("CoUninitialize", json!({"ok":true}));
|
|
result
|
|
}
|
|
|
|
fn get_locking_processes(path: &Path, logger: &Logger) -> Result<Vec<u32>, AppError> {
|
|
let mut session = 0u32;
|
|
let mut key = [0u16; 33];
|
|
logger.unsafe_enter("RmStartSession", json!({}));
|
|
let start_result = unsafe { RmStartSession(&mut session, Some(0), PWSTR(key.as_mut_ptr())) };
|
|
logger.unsafe_exit(
|
|
"RmStartSession",
|
|
json!({"status":start_result.0,"session":session}),
|
|
);
|
|
win32_ok(start_result, "RmStartSession")?;
|
|
|
|
let file = Utf16Arg::from_path(path);
|
|
let resources = [PCWSTR(file.as_ptr())];
|
|
logger.unsafe_enter("RmRegisterResources", json!({"path":path}));
|
|
let register_result = unsafe { RmRegisterResources(session, Some(&resources), None, None) };
|
|
logger.unsafe_exit("RmRegisterResources", json!({"status":register_result.0}));
|
|
if let Err(err) = win32_ok(register_result, "RmRegisterResources") {
|
|
let _ = end_restart_manager_session(session, logger);
|
|
return Err(err);
|
|
}
|
|
|
|
let mut needed = 0u32;
|
|
let mut count = 0u32;
|
|
let mut reasons = 0u32;
|
|
logger.unsafe_enter("RmGetList", json!({"phase":"probe"}));
|
|
let probe = unsafe { RmGetList(session, &mut needed, &mut count, None, &mut reasons) };
|
|
logger.unsafe_exit(
|
|
"RmGetList",
|
|
json!({"phase":"probe","status":probe.0,"needed":needed}),
|
|
);
|
|
if probe != ERROR_SUCCESS && probe != ERROR_MORE_DATA {
|
|
let _ = end_restart_manager_session(session, logger);
|
|
return win32_ok(probe, "RmGetList").map(|_| Vec::new());
|
|
}
|
|
if needed == 0 {
|
|
end_restart_manager_session(session, logger)?;
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut processes = vec![RM_PROCESS_INFO::default(); needed as usize];
|
|
count = needed;
|
|
logger.unsafe_enter(
|
|
"RmGetList",
|
|
json!({"phase":"fetch","capacity":processes.len()}),
|
|
);
|
|
let fetch = unsafe {
|
|
RmGetList(
|
|
session,
|
|
&mut needed,
|
|
&mut count,
|
|
Some(processes.as_mut_ptr()),
|
|
&mut reasons,
|
|
)
|
|
};
|
|
logger.unsafe_exit(
|
|
"RmGetList",
|
|
json!({"phase":"fetch","status":fetch.0,"count":count}),
|
|
);
|
|
end_restart_manager_session(session, logger)?;
|
|
win32_ok(fetch, "RmGetList")?;
|
|
if count as usize > processes.len() {
|
|
return Err(AppError::Message(
|
|
"Restart Manager count exceeded allocated buffer".into(),
|
|
));
|
|
}
|
|
Ok(processes
|
|
.into_iter()
|
|
.take(count as usize)
|
|
.map(|p| p.Process.dwProcessId)
|
|
.collect())
|
|
}
|
|
|
|
fn end_restart_manager_session(session: u32, logger: &Logger) -> Result<(), AppError> {
|
|
logger.unsafe_enter("RmEndSession", json!({"session":session}));
|
|
let result = unsafe { RmEndSession(session) };
|
|
logger.unsafe_exit("RmEndSession", json!({"status":result.0}));
|
|
win32_ok(result, "RmEndSession")
|
|
}
|
|
|
|
fn known_folder(id: &windows::core::GUID, logger: &Logger) -> Result<PathBuf, AppError> {
|
|
logger.unsafe_enter("SHGetKnownFolderPath", json!({"folder":format!("{id:?}")}));
|
|
let raw = unsafe { SHGetKnownFolderPath(id, KNOWN_FOLDER_FLAG(0), None)? };
|
|
logger.unsafe_exit(
|
|
"SHGetKnownFolderPath",
|
|
json!({"ptr_non_null":!raw.is_null()}),
|
|
);
|
|
if raw.is_null() {
|
|
return Err(AppError::Message(
|
|
"SHGetKnownFolderPath returned null".into(),
|
|
));
|
|
}
|
|
let path_result = pwstr_to_path(raw, logger);
|
|
logger.unsafe_enter("CoTaskMemFree", json!({}));
|
|
unsafe { CoTaskMemFree(Some(raw.0.cast())) };
|
|
logger.unsafe_exit("CoTaskMemFree", json!({"ok":true}));
|
|
path_result
|
|
}
|
|
|
|
fn pwstr_to_path(raw: PWSTR, logger: &Logger) -> Result<PathBuf, AppError> {
|
|
logger.unsafe_enter("PWSTR decode", json!({}));
|
|
unsafe {
|
|
let mut len = 0usize;
|
|
while *raw.0.add(len) != 0 {
|
|
len += 1;
|
|
}
|
|
let slice = std::slice::from_raw_parts(raw.0, len);
|
|
let path = String::from_utf16(slice)
|
|
.map_err(|_| AppError::Message("Invalid UTF-16 from Win32".into()))?;
|
|
logger.unsafe_exit("PWSTR decode", json!({"len":len}));
|
|
Ok(PathBuf::from(path))
|
|
}
|
|
}
|
|
|
|
fn close_handle(handle: HANDLE, logger: &Logger) -> Result<(), AppError> {
|
|
logger.unsafe_enter("CloseHandle", json!({}));
|
|
let result = unsafe { CloseHandle(handle) };
|
|
logger.unsafe_exit("CloseHandle", json!({"ok":result.is_ok()}));
|
|
result?;
|
|
Ok(())
|
|
}
|
|
|
|
fn close_registry_key(key: HKEY, logger: &Logger) -> Result<(), AppError> {
|
|
logger.unsafe_enter("RegCloseKey", json!({}));
|
|
let result = unsafe { RegCloseKey(key) };
|
|
logger.unsafe_exit("RegCloseKey", json!({"status":result.0}));
|
|
win32_ok(result, "RegCloseKey")
|
|
}
|
|
|
|
fn root_hkey(root: RegistryRoot) -> HKEY {
|
|
match root {
|
|
RegistryRoot::Hkcu => HKEY_CURRENT_USER,
|
|
RegistryRoot::Hklm => HKEY_LOCAL_MACHINE,
|
|
}
|
|
}
|
|
|
|
fn win32_ok(status: WIN32_ERROR, operation: &str) -> Result<(), AppError> {
|
|
if status == ERROR_SUCCESS {
|
|
Ok(())
|
|
} else {
|
|
Err(AppError::Message(format!(
|
|
"{operation} failed with Win32 error {}",
|
|
status.0
|
|
)))
|
|
}
|
|
}
|
|
|
|
struct Utf16Arg {
|
|
inner: Vec<u16>,
|
|
}
|
|
|
|
impl Utf16Arg {
|
|
fn from_path(path: &Path) -> Self {
|
|
Self {
|
|
inner: path
|
|
.as_os_str()
|
|
.encode_wide()
|
|
.chain(iter::once(0))
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn from_str(value: &str) -> Self {
|
|
Self {
|
|
inner: OsStr::new(value)
|
|
.encode_wide()
|
|
.chain(iter::once(0))
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn as_ptr(&self) -> *const u16 {
|
|
self.inner.as_ptr()
|
|
}
|
|
|
|
fn as_bytes(&self) -> &[u8] {
|
|
unsafe {
|
|
std::slice::from_raw_parts(
|
|
self.inner.as_ptr().cast::<u8>(),
|
|
self.inner.len() * std::mem::size_of::<u16>(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
struct TestDir {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl TestDir {
|
|
fn new(name: &str) -> Self {
|
|
let unique = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let path = std::env::temp_dir().join(format!(
|
|
"covenant-setup-win-test-{name}-{}-{unique}",
|
|
std::process::id()
|
|
));
|
|
fs::create_dir_all(&path).unwrap();
|
|
Self { path }
|
|
}
|
|
}
|
|
|
|
impl Drop for TestDir {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
|
|
fn quiet_logger() -> Logger {
|
|
Logger {
|
|
json: false,
|
|
quiet: true,
|
|
}
|
|
}
|
|
|
|
fn resolver() -> PathResolver {
|
|
PathResolver::with_roots_for_test(vec![
|
|
PathBuf::from("C:\\Program Files"),
|
|
PathBuf::from("C:\\Program Files (x86)"),
|
|
PathBuf::from("D:\\Windows"),
|
|
])
|
|
}
|
|
|
|
#[test]
|
|
fn requires_admin_matches_subpaths() {
|
|
let r = resolver();
|
|
assert!(r.requires_admin(Path::new("C:\\Program Files\\App\\bin")));
|
|
assert!(r.requires_admin(Path::new("C:\\Program Files (x86)\\Vendor\\app.exe")));
|
|
assert!(r.requires_admin(Path::new("D:\\Windows\\System32\\drivers")));
|
|
}
|
|
|
|
#[test]
|
|
fn requires_admin_matches_exact_root() {
|
|
let r = resolver();
|
|
assert!(r.requires_admin(Path::new("C:\\Program Files")));
|
|
assert!(r.requires_admin(Path::new("C:\\Program Files\\")));
|
|
}
|
|
|
|
#[test]
|
|
fn requires_admin_rejects_sibling_prefix() {
|
|
let r = resolver();
|
|
assert!(!r.requires_admin(Path::new("C:\\Program Files Custom\\App")));
|
|
assert!(!r.requires_admin(Path::new("C:\\Program Files2\\App")));
|
|
assert!(!r.requires_admin(Path::new("D:\\WindowsApps\\thing")));
|
|
}
|
|
|
|
#[test]
|
|
fn requires_admin_rejects_user_paths() {
|
|
let r = resolver();
|
|
assert!(!r.requires_admin(Path::new("C:\\Users\\alice\\AppData\\Local\\App")));
|
|
assert!(!r.requires_admin(Path::new("D:\\data\\App")));
|
|
assert!(!r.requires_admin(Path::new("E:\\")));
|
|
}
|
|
|
|
#[test]
|
|
fn requires_admin_is_case_insensitive() {
|
|
let r = resolver();
|
|
assert!(r.requires_admin(Path::new("c:\\PROGRAM FILES\\App")));
|
|
assert!(r.requires_admin(Path::new("D:\\windows\\System32")));
|
|
}
|
|
|
|
#[test]
|
|
fn requires_admin_normalizes_forward_slashes() {
|
|
let r = resolver();
|
|
assert!(r.requires_admin(Path::new("C:/Program Files/App/bin")));
|
|
assert!(r.requires_admin(Path::new("D:/Windows/System32")));
|
|
}
|
|
|
|
#[test]
|
|
fn requires_admin_handles_non_windows_roots() {
|
|
let r = PathResolver::with_roots_for_test(vec![PathBuf::from("E:\\Apps\\Program Files")]);
|
|
assert!(r.requires_admin(Path::new("E:\\Apps\\Program Files\\App")));
|
|
assert!(!r.requires_admin(Path::new("C:\\Program Files\\App")));
|
|
}
|
|
|
|
#[test]
|
|
fn requires_admin_with_empty_roots_returns_false() {
|
|
let r = PathResolver::with_roots_for_test(vec![]);
|
|
assert!(!r.requires_admin(Path::new("C:\\Program Files\\App")));
|
|
}
|
|
|
|
#[test]
|
|
fn create_directory_recursive_creates_nested_directories_and_noops_existing() {
|
|
let temp = TestDir::new("create-dir");
|
|
let nested = temp.path.join("one").join("two").join("three");
|
|
|
|
create_directory_recursive(&nested, &quiet_logger()).unwrap();
|
|
create_directory_recursive(&nested, &quiet_logger()).unwrap();
|
|
|
|
assert!(nested.is_dir());
|
|
}
|
|
|
|
#[test]
|
|
fn copy_file_copies_bytes_to_destination() {
|
|
let temp = TestDir::new("copy-file");
|
|
let source = temp.path.join("source.bin");
|
|
let destination = temp.path.join("destination.bin");
|
|
fs::write(&source, b"copy me").unwrap();
|
|
|
|
copy_file(&source, &destination, &quiet_logger()).unwrap();
|
|
|
|
assert_eq!(fs::read(destination).unwrap(), b"copy me");
|
|
}
|
|
|
|
#[test]
|
|
fn remove_directory_if_exists_removes_empty_and_defers_nonempty() {
|
|
let temp = TestDir::new("remove-dir");
|
|
let empty = temp.path.join("empty");
|
|
let nonempty = temp.path.join("nonempty");
|
|
fs::create_dir_all(&empty).unwrap();
|
|
fs::create_dir_all(&nonempty).unwrap();
|
|
fs::write(nonempty.join("child.txt"), b"child").unwrap();
|
|
|
|
remove_directory_if_exists(&empty, &quiet_logger()).unwrap();
|
|
remove_directory_if_exists(&nonempty, &quiet_logger()).unwrap();
|
|
remove_directory_if_exists(&temp.path.join("missing"), &quiet_logger()).unwrap();
|
|
|
|
assert!(!empty.exists());
|
|
assert!(nonempty.is_dir());
|
|
}
|
|
|
|
#[test]
|
|
fn remove_file_with_fallback_deletes_existing_file_and_noops_missing() {
|
|
let temp = TestDir::new("remove-file");
|
|
let file = temp.path.join("payload.bin");
|
|
fs::write(&file, b"delete me").unwrap();
|
|
|
|
remove_file_with_fallback(&file, &quiet_logger()).unwrap();
|
|
remove_file_with_fallback(&file, &quiet_logger()).unwrap();
|
|
|
|
assert!(!file.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn is_elevated_queries_current_process_token() {
|
|
let _ = is_elevated(&quiet_logger()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn delete_registry_tree_ignores_unique_missing_hkcu_key() {
|
|
let unique = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let subkey = format!(
|
|
"Software\\CovenantSetupTests\\missing-{}-{unique}",
|
|
std::process::id()
|
|
);
|
|
|
|
delete_registry_tree(RegistryRoot::Hkcu, &subkey, &quiet_logger()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn create_shortcut_writes_lnk_file_with_optional_fields() {
|
|
let temp = TestDir::new("shortcut");
|
|
let shortcut = temp.path.join("sample.lnk");
|
|
let target = std::env::current_exe().unwrap();
|
|
let working_directory = target.parent().unwrap();
|
|
|
|
create_shortcut(
|
|
&shortcut,
|
|
&target,
|
|
Some("--help"),
|
|
Some(working_directory),
|
|
Some("Sample shortcut"),
|
|
&quiet_logger(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(shortcut.is_file());
|
|
}
|
|
|
|
#[test]
|
|
fn restart_manager_reports_locking_processes_for_unlocked_file() {
|
|
let temp = TestDir::new("restart-manager");
|
|
let file = temp.path.join("unlocked.txt");
|
|
fs::write(&file, b"unlocked").unwrap();
|
|
|
|
match get_locking_processes(&file, &quiet_logger()) {
|
|
Ok(pids) => assert!(pids.iter().all(|pid| *pid > 0)),
|
|
Err(err) => assert!(err.to_string().contains("RmStartSession failed")),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pwstr_to_path_decodes_valid_utf16_and_rejects_invalid_utf16() {
|
|
let mut valid = Utf16Arg::from_str("C:\\Temp").inner;
|
|
let path = pwstr_to_path(PWSTR(valid.as_mut_ptr()), &quiet_logger()).unwrap();
|
|
assert_eq!(path, PathBuf::from("C:\\Temp"));
|
|
|
|
let mut invalid = vec![0xD800, 0];
|
|
let err = pwstr_to_path(PWSTR(invalid.as_mut_ptr()), &quiet_logger())
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(err.contains("Invalid UTF-16"));
|
|
}
|
|
|
|
#[test]
|
|
fn win32_ok_accepts_success_and_formats_errors() {
|
|
win32_ok(ERROR_SUCCESS, "Example").unwrap();
|
|
|
|
let err = win32_ok(WIN32_ERROR(5), "Example").unwrap_err().to_string();
|
|
assert_eq!(err, "Example failed with Win32 error 5");
|
|
}
|
|
|
|
#[test]
|
|
fn quote_passthrough_when_no_special_chars() {
|
|
assert_eq!(quote_command_line_arg("install"), "install");
|
|
assert_eq!(
|
|
quote_command_line_arg("C:\\Apps\\foo.exe"),
|
|
"C:\\Apps\\foo.exe"
|
|
);
|
|
assert_eq!(quote_command_line_arg("--json"), "--json");
|
|
}
|
|
|
|
#[test]
|
|
fn quote_wraps_when_contains_space_or_tab() {
|
|
assert_eq!(quote_command_line_arg("hello world"), "\"hello world\"");
|
|
assert_eq!(quote_command_line_arg("a\tb"), "\"a\tb\"");
|
|
assert_eq!(
|
|
quote_command_line_arg("C:\\Program Files\\App\\install.toml"),
|
|
"\"C:\\Program Files\\App\\install.toml\""
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn quote_escapes_embedded_double_quotes() {
|
|
assert_eq!(quote_command_line_arg("a\"b"), "\"a\\\"b\"");
|
|
assert_eq!(quote_command_line_arg("\""), "\"\\\"\"");
|
|
}
|
|
|
|
#[test]
|
|
fn quote_doubles_trailing_backslashes_before_closing_quote() {
|
|
// "C:\Path\" must serialize as "\"C:\\Path\\\\\"" so the parser sees
|
|
// the backslashes as literals and the final quote as the terminator.
|
|
assert_eq!(
|
|
quote_command_line_arg("C:\\Path with space\\"),
|
|
"\"C:\\Path with space\\\\\""
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn quote_doubles_backslashes_only_when_followed_by_quote() {
|
|
// \\ inside an unquoted-needing arg stays \\ when not before a quote.
|
|
assert_eq!(quote_command_line_arg("a\\\\b c"), "\"a\\\\b c\"");
|
|
// \\ immediately before a literal quote becomes \\\\\".
|
|
assert_eq!(quote_command_line_arg("a\\\\\"b"), "\"a\\\\\\\\\\\"b\"");
|
|
}
|
|
|
|
#[test]
|
|
fn quote_emits_explicit_empty_argument() {
|
|
assert_eq!(quote_command_line_arg(""), "\"\"");
|
|
}
|
|
|
|
#[test]
|
|
fn quote_round_trips_through_argv_rules() {
|
|
// Sanity-check that re-parsing the quoted form per the
|
|
// CommandLineToArgvW spec recovers the original argument.
|
|
for input in [
|
|
"simple",
|
|
"with space",
|
|
"a\"b",
|
|
"C:\\Program Files\\app\\bin",
|
|
"C:\\Path with space\\",
|
|
"trailing\\\\",
|
|
"embedded\\\"quote",
|
|
"",
|
|
] {
|
|
let quoted = quote_command_line_arg(input);
|
|
let parsed = parse_argv_for_test("ed);
|
|
assert_eq!(parsed, vec![input.to_string()], "input was {input:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn utf16_arg_as_bytes_includes_null_terminator() {
|
|
let arg = Utf16Arg::from_str("A");
|
|
assert_eq!(arg.inner, vec![65, 0]);
|
|
assert_eq!(
|
|
arg.as_bytes().len(),
|
|
arg.inner.len() * std::mem::size_of::<u16>()
|
|
);
|
|
assert_eq!(arg.as_bytes(), &[65, 0, 0, 0]);
|
|
}
|
|
|
|
// Reference parser following the CommandLineToArgvW algorithm, used only
|
|
// to validate the encoder above.
|
|
fn parse_argv_for_test(line: &str) -> Vec<String> {
|
|
let mut args = Vec::new();
|
|
let mut current = String::new();
|
|
let mut in_quotes = false;
|
|
let mut backslashes = 0usize;
|
|
let mut started = false;
|
|
|
|
let flush_backslashes = |current: &mut String, n: usize| {
|
|
for _ in 0..n {
|
|
current.push('\\');
|
|
}
|
|
};
|
|
|
|
for c in line.chars() {
|
|
match c {
|
|
'\\' => {
|
|
backslashes += 1;
|
|
started = true;
|
|
}
|
|
'"' => {
|
|
flush_backslashes(&mut current, backslashes / 2);
|
|
if backslashes % 2 == 1 {
|
|
current.push('"');
|
|
} else {
|
|
in_quotes = !in_quotes;
|
|
}
|
|
backslashes = 0;
|
|
started = true;
|
|
}
|
|
' ' | '\t' if !in_quotes => {
|
|
flush_backslashes(&mut current, backslashes);
|
|
backslashes = 0;
|
|
if started {
|
|
args.push(std::mem::take(&mut current));
|
|
started = false;
|
|
}
|
|
}
|
|
_ => {
|
|
flush_backslashes(&mut current, backslashes);
|
|
backslashes = 0;
|
|
current.push(c);
|
|
started = true;
|
|
}
|
|
}
|
|
}
|
|
flush_backslashes(&mut current, backslashes);
|
|
if started {
|
|
args.push(current);
|
|
}
|
|
args
|
|
}
|
|
}
|