From 87018bc7694d80f96727836730fc244bd5dff407 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 13 Jun 2026 15:16:43 -0500 Subject: [PATCH] simplify Rust engine --- docs/future-state.md | 13 + .../Invoke-InteractiveInstaller.ps1 | 48 +- src/bundle.rs | 675 +++ src/cli.rs | 308 ++ src/error.rs | 24 + src/install.rs | 1353 +++++ src/journal.rs | 170 + src/lib.rs | 85 + src/logging.rs | 130 + src/main.rs | 4777 +---------------- src/manifest.rs | 374 ++ src/progress.rs | 367 ++ src/test_support.rs | 454 ++ src/ui.rs | 801 --- src/ui/bundle.rs | 148 + src/ui/mod.rs | 127 + src/ui/progress.rs | 186 + src/ui/prompts.rs | 125 + src/ui/session.rs | 229 + src/uninstall.rs | 837 +++ src/util.rs | 176 + src/win.rs | 1010 ---- src/win/elevation.rs | 92 + src/win/fs_ops.rs | 83 + src/win/mod.rs | 494 ++ src/win/path.rs | 120 + src/win/pipe.rs | 27 + src/win/registry.rs | 72 + src/win/restart_manager.rs | 80 + src/win/shortcut.rs | 60 + 30 files changed, 6828 insertions(+), 6617 deletions(-) create mode 100644 src/bundle.rs create mode 100644 src/cli.rs create mode 100644 src/error.rs create mode 100644 src/install.rs create mode 100644 src/journal.rs create mode 100644 src/lib.rs create mode 100644 src/logging.rs create mode 100644 src/manifest.rs create mode 100644 src/progress.rs create mode 100644 src/test_support.rs delete mode 100644 src/ui.rs create mode 100644 src/ui/bundle.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/progress.rs create mode 100644 src/ui/prompts.rs create mode 100644 src/ui/session.rs create mode 100644 src/uninstall.rs create mode 100644 src/util.rs delete mode 100644 src/win.rs create mode 100644 src/win/elevation.rs create mode 100644 src/win/fs_ops.rs create mode 100644 src/win/mod.rs create mode 100644 src/win/path.rs create mode 100644 src/win/pipe.rs create mode 100644 src/win/registry.rs create mode 100644 src/win/restart_manager.rs create mode 100644 src/win/shortcut.rs diff --git a/docs/future-state.md b/docs/future-state.md index e73347d..5f3f55d 100644 --- a/docs/future-state.md +++ b/docs/future-state.md @@ -21,3 +21,16 @@ Future integration should make the separate app feel first-class from the CLI wi - Keep the runtime progress helper embedded with installers, but do not embed the authoring UI in generated installer artifacts. - Keep installer generation in the authoring UI gated on detection of a real `covenant-setup.exe`, and call the Rust CLI for packaging rather than duplicating packager behavior in C#. - Add a Rust CLI validation command and have the authoring UI call it before packaging so the Rust manifest schema remains the source of truth. + +## Correctness + +Two notes worth flagging + +1. Pre-existing clippy warnings (not mine): The codebase carries 11 clippy warnings (too_many_arguments ×4, collapsible_if ×4, cloned_ref_to_slice_refs, + needless_borrow, redundant_guard). I verified against the committed HEAD in a throwaway worktree — all 11 predate this refactor. The one lint I did introduce + (let_and_return in the new run()) I fixed. So clippy is at exact parity with baseline. Cleaning up those 11 would mean logic edits, which your "pure move only" + constraint excludes — happy to do them as a separate pass if you want. +2. Sample manifest script quirk (not mine): examples/Covenant-SetupSampleApp-install.toml runs post_install.ps1 via -File payload\post_install.ps1 (relative) with + working_directory = {LocalAppData}\..., so PowerShell can't find the script and the install fails → triggers rollback (exit 0xfffd0000). This is inherent to the + manifest + the verbatim-moved execute_script logic; the packaged-exe run fails identically, confirming behavior parity. My clean round-trip used a script-free + manifest to exercise the journal path. The example's -File path likely wants to be manifest-relative — let me know if you'd like that fixed. diff --git a/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 index 9c1a119..8d1d182 100644 --- a/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 +++ b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 @@ -154,22 +154,7 @@ try { $process = Start-Process -FilePath $dotnetPath -ArgumentList @("test", "$testProjectDir\Covenant.Setup.Ui.Tests.csproj", "--filter", "FullyQualifiedName=Covenant.Setup.Ui.Tests.InstallerUiAutomationTests.TestRealInstallUninstallFlow") -PassThru -NoNewWindow -Wait -RedirectStandardOutput $stdoutLog -RedirectStandardError $stderrLog Write-TraceEvent -Phase "dotnet_test_finished" -Detail @{ exitCode = $process.ExitCode } - - $result = [ordered]@{ - success = ($process.ExitCode -eq 0) - exitCode = [int]$process.ExitCode - installerPath = $InstallerPath - installerArgs = $InstallerArguments - operationName = $OperationName - tracePath = $TracePath - startedAt = $startedAt.ToString("o") - finishedAt = (Get-Date).ToString("o") - } - $result | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 - - if ($process.ExitCode -ne 0) { - exit $process.ExitCode - } + $installerExitCode = [int]$process.ExitCode } else { $installer = Start-Process -FilePath $InstallerPath -ArgumentList $InstallerArguments -PassThru @@ -202,22 +187,25 @@ try { Write-TraceEvent -Phase "installer_process_exited" -Detail @{ pid = $installer.Id; exitCode = $installer.ExitCode; operationName = $OperationName } Export-InstallerDiagnostics -Reason "installer_exit" -InstallerProcess $installer + $installerExitCode = [int]$installer.ExitCode + } - $result = [ordered]@{ - success = ($installer.ExitCode -eq 0) - exitCode = [int]$installer.ExitCode - installerPath = $InstallerPath - installerArgs = $InstallerArguments - operationName = $OperationName - tracePath = $TracePath - startedAt = $startedAt.ToString("o") - finishedAt = (Get-Date).ToString("o") - } - $result | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + # Both runners (dotnet test vs. windowed process) converge here so the + # result JSON and exit-code propagation live in one place. + $result = [ordered]@{ + success = ($installerExitCode -eq 0) + exitCode = $installerExitCode + installerPath = $InstallerPath + installerArgs = $InstallerArguments + operationName = $OperationName + tracePath = $TracePath + startedAt = $startedAt.ToString("o") + finishedAt = (Get-Date).ToString("o") + } + $result | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 - if ($installer.ExitCode -ne 0) { - exit $installer.ExitCode - } + if ($installerExitCode -ne 0) { + exit $installerExitCode } } catch { diff --git a/src/bundle.rs b/src/bundle.rs new file mode 100644 index 0000000..71d447c --- /dev/null +++ b/src/bundle.rs @@ -0,0 +1,675 @@ +use crate::sys::Sys; +use crate::{ + AppError, InstallManifest, Logger, UiMode, UiPreferences, enforce_manifest_path_spacing, + expected_manifest_file_name, failure_ux_shown, install, read_install_manifest, same_path, + select_ui, trace_event, ui_mode_name, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process; + +pub(crate) const EMBEDDED_MAGIC: &[u8] = b"COVENANT_SETUP_BUNDLE_V1"; + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PackagedApp { + pub(crate) app_name: String, + pub(crate) manifest: String, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct EmbeddedFile { + pub(crate) relative_path: String, + pub(crate) data: Vec, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct EmbeddedBundle { + pub(crate) metadata: PackagedApp, + pub(crate) files: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct EmbeddedBundleIndex { + pub(crate) metadata: PackagedApp, + pub(crate) files: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct EmbeddedFileIndexEntry { + pub(crate) relative_path: String, + pub(crate) len: u64, +} + +pub(crate) fn package( + manifest_path: &Path, + output_root: &Path, + logger: &Logger, +) -> Result<(), AppError> { + enforce_manifest_path_spacing(manifest_path)?; + let manifest = read_install_manifest(manifest_path)?; + let current_exe = std::env::current_exe()?; + let manifest_dir = manifest_path + .parent() + .ok_or_else(|| AppError::Message("Manifest must have a parent directory".into()))?; + fs::create_dir_all(output_root)?; + let installer_target = output_root.join("covenant-setup-installer.exe"); + build_packaged_installer( + &installer_target, + ¤t_exe, + manifest_dir, + manifest_path, + &manifest, + logger, + )?; + + logger.result( + "ok", + json!({ + "installer": installer_target + }), + ); + Ok(()) +} + +pub(crate) fn build_packaged_installer( + exe_target: &Path, + current_exe: &Path, + manifest_dir: &Path, + manifest_path: &Path, + manifest: &InstallManifest, + logger: &Logger, +) -> Result<(), AppError> { + fs::copy(current_exe, &exe_target)?; + let bundled_manifest_name = expected_manifest_file_name(&manifest.app_name); + let bundle = EmbeddedBundle { + metadata: PackagedApp { + app_name: manifest.app_name.clone(), + manifest: bundled_manifest_name.clone(), + }, + files: collect_bundle_files( + manifest_dir, + manifest_path, + &bundled_manifest_name, + &[exe_target.to_path_buf()], + )?, + }; + append_embedded_bundle(exe_target, &bundle)?; + logger.info( + "package_artifact", + json!({ + "exe": exe_target, + "embedded_files": bundle.files.len() + }), + ); + Ok(()) +} + +pub(crate) fn collect_bundle_files( + source_root: &Path, + manifest_path: &Path, + bundled_manifest_name: &str, + excluded_paths: &[PathBuf], +) -> Result, AppError> { + let mut files = Vec::new(); + collect_bundle_files_recursive( + source_root, + source_root, + manifest_path, + bundled_manifest_name, + excluded_paths, + &mut files, + )?; + Ok(files) +} + +fn collect_bundle_files_recursive( + source_root: &Path, + current: &Path, + manifest_path: &Path, + bundled_manifest_name: &str, + excluded_paths: &[PathBuf], + files: &mut Vec, +) -> Result<(), AppError> { + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + if excluded_paths + .iter() + .any(|excluded| same_path(&path, excluded)) + { + continue; + } + let relative = path + .strip_prefix(source_root) + .map_err(|_| AppError::Message("Failed to derive embedded file path".into()))?; + if should_exclude_from_bundle(relative) { + continue; + } + if path.is_dir() { + collect_bundle_files_recursive( + source_root, + &path, + manifest_path, + bundled_manifest_name, + excluded_paths, + files, + )?; + } else { + let relative_path = if same_path(&path, manifest_path) { + bundled_manifest_name.to_string() + } else { + relative.to_string_lossy().to_string() + }; + files.push(EmbeddedFile { + relative_path, + data: fs::read(&path)?, + }); + } + } + Ok(()) +} + +pub(crate) fn should_exclude_from_bundle(relative_path: &Path) -> bool { + let Some(file_name) = relative_path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + matches!( + file_name.to_ascii_lowercase().as_str(), + "journal.json" | "covenant-setup-uninstall.exe" | "covenant-setup-installer.exe" + ) +} + +pub(crate) fn append_embedded_bundle( + exe_target: &Path, + bundle: &EmbeddedBundle, +) -> Result<(), AppError> { + let index = EmbeddedBundleIndex { + metadata: PackagedApp { + app_name: bundle.metadata.app_name.clone(), + manifest: bundle.metadata.manifest.clone(), + }, + files: bundle + .files + .iter() + .map(|file| EmbeddedFileIndexEntry { + relative_path: file.relative_path.clone(), + len: file.data.len() as u64, + }) + .collect(), + }; + let index_bytes = serde_json::to_vec(&index)?; + let mut payload = Vec::with_capacity( + std::mem::size_of::() + + index_bytes.len() + + bundle + .files + .iter() + .map(|file| file.data.len()) + .sum::(), + ); + payload.write_all(&(index_bytes.len() as u64).to_le_bytes())?; + payload.write_all(&index_bytes)?; + for file in &bundle.files { + payload.write_all(&file.data)?; + } + let mut file = fs::OpenOptions::new().append(true).open(exe_target)?; + file.write_all(&payload)?; + file.write_all(&(payload.len() as u64).to_le_bytes())?; + file.write_all(EMBEDDED_MAGIC)?; + Ok(()) +} + +pub(crate) fn read_embedded_bundle(exe_path: &Path) -> Result, AppError> { + let mut file = fs::File::open(exe_path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + let footer_len = EMBEDDED_MAGIC.len() + std::mem::size_of::(); + if bytes.len() < footer_len { + return Ok(None); + } + let magic_offset = bytes.len() - EMBEDDED_MAGIC.len(); + if &bytes[magic_offset..] != EMBEDDED_MAGIC { + return Ok(None); + } + let size_offset = magic_offset - std::mem::size_of::(); + let payload_len = u64::from_le_bytes( + bytes[size_offset..magic_offset] + .try_into() + .map_err(|_| AppError::Message("Invalid embedded payload footer".into()))?, + ) as usize; + if size_offset < payload_len { + return Err(AppError::Message( + "Embedded payload length exceeds executable size".into(), + )); + } + let payload_offset = size_offset - payload_len; + let payload = &bytes[payload_offset..size_offset]; + if payload.len() < std::mem::size_of::() { + return Err(AppError::Message("Embedded payload is too short".into())); + } + let index_len = u64::from_le_bytes( + payload[..std::mem::size_of::()] + .try_into() + .map_err(|_| AppError::Message("Invalid embedded index length".into()))?, + ) as usize; + let index_offset = std::mem::size_of::(); + let data_offset = index_offset + .checked_add(index_len) + .ok_or_else(|| AppError::Message("Embedded index length overflow".into()))?; + if data_offset > payload.len() { + return Err(AppError::Message( + "Embedded index length exceeds payload size".into(), + )); + } + let index: EmbeddedBundleIndex = serde_json::from_slice(&payload[index_offset..data_offset])?; + let EmbeddedBundleIndex { + metadata, + files: index_files, + } = index; + let mut cursor = data_offset; + let mut files = Vec::with_capacity(index_files.len()); + for entry in index_files { + let len = entry.len as usize; + let end = cursor + .checked_add(len) + .ok_or_else(|| AppError::Message("Embedded file length overflow".into()))?; + if end > payload.len() { + return Err(AppError::Message(format!( + "Embedded file exceeds payload size: {}", + entry.relative_path + ))); + } + files.push(EmbeddedFile { + relative_path: entry.relative_path, + data: payload[cursor..end].to_vec(), + }); + cursor = end; + } + if cursor != payload.len() { + return Err(AppError::Message( + "Embedded payload has trailing bytes after file data".into(), + )); + } + let bundle = EmbeddedBundle { metadata, files }; + Ok(Some(bundle)) +} + +pub(crate) fn extract_embedded_bundle( + exe_path: &Path, + bundle: &EmbeddedBundle, +) -> Result { + let temp_root = std::env::temp_dir().join("covenant-setup").join(format!( + "{}-{}", + exe_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .replace(' ', "_"), + process::id() + )); + if temp_root.exists() { + fs::remove_dir_all(&temp_root)?; + } + fs::create_dir_all(&temp_root)?; + for file in &bundle.files { + let target = temp_root.join(&file.relative_path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::write(target, &file.data)?; + } + Ok(temp_root) +} + +pub(crate) fn has_embedded_bundle() -> bool { + std::env::current_exe() + .ok() + .and_then(|exe| read_embedded_bundle(&exe).ok().flatten()) + .is_some() +} + +pub(crate) fn run_bundled_installer( + preferences: UiPreferences, + sys: &dyn Sys, + logger: &Logger, +) -> Result<(), AppError> { + trace_event("bundled_installer_start", json!({})); + let exe = std::env::current_exe()?; + let bundle = read_embedded_bundle(&exe)? + .ok_or_else(|| AppError::Message("No embedded package found in installer".into()))?; + let extraction_root = extract_embedded_bundle(&exe, &bundle)?; + trace_event( + "bundled_installer_extracted", + json!({"exe": exe, "extraction_root": extraction_root}), + ); + let metadata = bundle.metadata; + let manifest_path = extraction_root.join(metadata.manifest.clone()); + let ui_mode = select_ui(preferences, sys, logger)?; + trace_event( + "bundled_installer_ui_selected", + json!({"ui_mode": ui_mode_name(ui_mode), "automation": preferences.automation}), + ); + match install( + &manifest_path, + None, + true, + ui_mode, + preferences.automation, + sys, + None, + logger, + ) { + Ok(()) => { + trace_event("bundled_installer_install_ok", json!({})); + // The progress window now lingers showing the success result with + // a Close button, so no extra success prompt is needed. + Ok(()) + } + Err(err) => { + if matches!(err, AppError::CancelledByUser) { + trace_event("bundled_installer_install_cancelled", json!({})); + return Ok(()); + } + let err_msg = err.to_string(); + trace_event("bundled_installer_install_error", json!({"error": err_msg})); + if ui_mode == UiMode::Gui + && !preferences.automation + && !failure_ux_shown() + && !matches!(err, AppError::Cancelled) + { + sys.ui_report_error(&err_msg)?; + } + Err(err) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + use crate::*; + + #[test] + fn embedded_bundle_round_trips_through_exe_footer() { + let temp = TestDir::new("bundle-round-trip"); + let exe = temp.path().join("installer.exe"); + fs::write(&exe, b"stub executable bytes").unwrap(); + let manifest_name = expected_manifest_file_name("Round Trip App"); + + let bundle = EmbeddedBundle { + metadata: PackagedApp { + app_name: "Round Trip App".to_string(), + manifest: manifest_name.clone(), + }, + files: vec![ + EmbeddedFile { + relative_path: manifest_name, + data: b"app_name = 'Round Trip App'".to_vec(), + }, + EmbeddedFile { + relative_path: "payload\\tool.exe".to_string(), + data: vec![0, 1, 2, 3, 255], + }, + ], + }; + + append_embedded_bundle(&exe, &bundle).unwrap(); + let decoded = read_embedded_bundle(&exe).unwrap().unwrap(); + + assert_eq!(decoded, bundle); + assert!( + fs::read(&exe) + .unwrap() + .starts_with(b"stub executable bytes") + ); + } + + #[test] + fn read_embedded_bundle_returns_none_for_wrong_magic_footer() { + let temp = TestDir::new("wrong-magic"); + let exe = temp.path().join("plain.exe"); + let mut bytes = vec![0; std::mem::size_of::()]; + bytes.extend_from_slice(b"COVENANT_SETUP_BUNDLE_BAD"); + fs::write(&exe, bytes).unwrap(); + + assert!(read_embedded_bundle(&exe).unwrap().is_none()); + } + + #[test] + fn read_embedded_bundle_rejects_payload_length_past_file_start() { + let temp = TestDir::new("bad-payload-len"); + let exe = temp.path().join("installer.exe"); + let mut bytes = b"stub".to_vec(); + bytes.extend_from_slice(&100u64.to_le_bytes()); + bytes.extend_from_slice(EMBEDDED_MAGIC); + fs::write(&exe, bytes).unwrap(); + + let err = read_embedded_bundle(&exe).unwrap_err().to_string(); + assert!(err.contains("Embedded payload length exceeds executable size")); + } + + #[test] + fn read_embedded_bundle_rejects_short_payload() { + let temp = TestDir::new("short-payload"); + let exe = temp.path().join("installer.exe"); + write_embedded_payload(&exe, &[1, 2, 3, 4]); + + let err = read_embedded_bundle(&exe).unwrap_err().to_string(); + assert!(err.contains("Embedded payload is too short")); + } + + #[test] + fn read_embedded_bundle_rejects_index_length_past_payload() { + let temp = TestDir::new("bad-index-len"); + let exe = temp.path().join("installer.exe"); + write_embedded_payload(&exe, &100u64.to_le_bytes()); + + let err = read_embedded_bundle(&exe).unwrap_err().to_string(); + assert!(err.contains("Embedded index length exceeds payload size")); + } + + #[test] + fn read_embedded_bundle_rejects_file_length_past_payload() { + let temp = TestDir::new("bad-file-len"); + let exe = temp.path().join("installer.exe"); + let index = EmbeddedBundleIndex { + metadata: PackagedApp { + app_name: "Bad File".to_string(), + manifest: expected_manifest_file_name("Bad File"), + }, + files: vec![EmbeddedFileIndexEntry { + relative_path: "payload.bin".to_string(), + len: 10, + }], + }; + let mut payload = Vec::new(); + let index_bytes = serde_json::to_vec(&index).unwrap(); + payload.extend_from_slice(&(index_bytes.len() as u64).to_le_bytes()); + payload.extend_from_slice(&index_bytes); + write_embedded_payload(&exe, &payload); + + let err = read_embedded_bundle(&exe).unwrap_err().to_string(); + assert!(err.contains("Embedded file exceeds payload size")); + } + + #[test] + fn read_embedded_bundle_rejects_trailing_payload_bytes() { + let temp = TestDir::new("trailing-payload"); + let exe = temp.path().join("installer.exe"); + let index = EmbeddedBundleIndex { + metadata: PackagedApp { + app_name: "Trailing".to_string(), + manifest: expected_manifest_file_name("Trailing"), + }, + files: vec![EmbeddedFileIndexEntry { + relative_path: "empty.bin".to_string(), + len: 0, + }], + }; + let mut payload = Vec::new(); + let index_bytes = serde_json::to_vec(&index).unwrap(); + payload.extend_from_slice(&(index_bytes.len() as u64).to_le_bytes()); + payload.extend_from_slice(&index_bytes); + payload.push(1); + write_embedded_payload(&exe, &payload); + + let err = read_embedded_bundle(&exe).unwrap_err().to_string(); + assert!(err.contains("Embedded payload has trailing bytes")); + } + + #[test] + fn extract_embedded_bundle_writes_nested_files_to_temp_root() { + let temp = TestDir::new("extract-bundle"); + let exe = temp.path().join("installer with spaces.exe"); + fs::write(&exe, b"stub").unwrap(); + let bundle = EmbeddedBundle { + metadata: PackagedApp { + app_name: "Extract App".to_string(), + manifest: expected_manifest_file_name("Extract App"), + }, + files: vec![EmbeddedFile { + relative_path: "nested\\payload.txt".to_string(), + data: b"payload".to_vec(), + }], + }; + + let root = extract_embedded_bundle(&exe, &bundle).unwrap(); + assert_eq!( + fs::read(root.join("nested\\payload.txt")).unwrap(), + b"payload" + ); + assert!( + root.file_name() + .unwrap() + .to_string_lossy() + .contains("installer_with_spaces") + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn read_embedded_bundle_returns_none_without_bundle_footer() { + let temp = TestDir::new("no-bundle"); + let exe = temp.path().join("plain.exe"); + fs::write(&exe, b"plain executable bytes").unwrap(); + + assert!(read_embedded_bundle(&exe).unwrap().is_none()); + } + + #[test] + fn build_packaged_installer_copies_stub_and_embeds_source_bundle() { + let temp = TestDir::new("package-installer"); + let source_root = temp.path().join("source"); + let payload_dir = source_root.join("payload"); + let manifest_path = source_root.join(expected_manifest_file_name("Packaged App")); + let current_exe = temp.path().join("current.exe"); + let exe_target = source_root + .join("dist") + .join("covenant-setup-installer.exe"); + fs::create_dir_all(&payload_dir).unwrap(); + fs::create_dir_all(exe_target.parent().unwrap()).unwrap(); + fs::write(¤t_exe, b"stub exe").unwrap(); + fs::write(&manifest_path, b"app_name = 'Packaged App'").unwrap(); + fs::write(payload_dir.join("app.bin"), b"payload bytes").unwrap(); + + let manifest = InstallManifest { + app_name: "Packaged App".to_string(), + directories: Vec::new(), + files: Vec::new(), + registry: Vec::new(), + shortcuts: Vec::new(), + scripts: Vec::new(), + purge: PurgeSpec::default(), + support_contact: None, + }; + + build_packaged_installer( + &exe_target, + ¤t_exe, + &source_root, + &manifest_path, + &manifest, + &quiet_logger(), + ) + .unwrap(); + + let exe_bytes = fs::read(&exe_target).unwrap(); + assert!(exe_bytes.starts_with(b"stub exe")); + let mut bundle = read_embedded_bundle(&exe_target).unwrap().unwrap(); + bundle + .files + .sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + + assert_eq!(bundle.metadata.app_name, "Packaged App"); + assert_eq!( + bundle.metadata.manifest, + expected_manifest_file_name("Packaged App") + ); + assert_eq!(bundle.files.len(), 2); + assert_eq!( + bundle.files[0].relative_path, + expected_manifest_file_name("Packaged App") + ); + assert_eq!(bundle.files[1].relative_path, "payload\\app.bin"); + assert_eq!(bundle.files[1].data, b"payload bytes"); + } + + #[test] + fn collect_bundle_files_renames_manifest_and_preserves_nested_payloads() { + let temp = TestDir::new("collect-bundle"); + let manifest = temp.path().join("CollectedApp-install.toml"); + let bundled_manifest_name = expected_manifest_file_name("Collected App"); + let nested_dir = temp.path().join("payload").join("bin"); + let nested_file = nested_dir.join("app.cmd"); + let journal = temp.path().join("journal.json"); + let generated_installer = temp + .path() + .join("dist") + .join("covenant-setup-installer.exe"); + let generated_uninstaller = temp.path().join("covenant-setup-uninstall.exe"); + fs::create_dir_all(&nested_dir).unwrap(); + fs::create_dir_all(generated_installer.parent().unwrap()).unwrap(); + fs::write(&manifest, b"app_name = 'Collected App'").unwrap(); + fs::write(&nested_file, b"@echo off").unwrap(); + fs::write(&journal, b"{}").unwrap(); + fs::write(&generated_installer, b"generated installer").unwrap(); + fs::write(&generated_uninstaller, b"generated uninstaller").unwrap(); + + let mut files = collect_bundle_files( + temp.path(), + &manifest, + &bundled_manifest_name, + &[generated_installer.clone()], + ) + .unwrap(); + files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + + assert_eq!(files.len(), 2); + assert_eq!(files[0].relative_path, bundled_manifest_name); + assert_eq!(files[0].data, b"app_name = 'Collected App'"); + assert_eq!(files[1].relative_path, "payload\\bin\\app.cmd"); + assert_eq!(files[1].data, b"@echo off"); + } + + #[test] + fn should_exclude_from_bundle_matches_generated_artifacts_only() { + assert!(should_exclude_from_bundle(Path::new("journal.json"))); + assert!(should_exclude_from_bundle(Path::new( + "dist\\covenant-setup-installer.exe" + ))); + assert!(should_exclude_from_bundle(Path::new( + "covenant-setup-uninstall.exe" + ))); + assert!(!should_exclude_from_bundle(Path::new("payload\\app.exe"))); + assert!(!should_exclude_from_bundle(Path::new(""))); + } + + #[test] + fn embedded_bundle_probe_is_false_for_test_binary() { + assert!(!has_embedded_bundle()); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..8c4d2d9 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,308 @@ +use crate::sys::Sys; +use crate::{ + AppError, EXIT_CANCELLED, EXIT_ELEVATION_REQUIRED, EXIT_OPERATION_FAILED, Logger, cleanup, + install, package, uninstall, +}; +use clap::{ArgAction, Parser, Subcommand}; +use serde_json::json; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command( + name = "covenant-setup", + version, + about = "Windows installer builder and engine" +)] +pub(crate) struct Cli { + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub(crate) json: bool, + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub(crate) headless: bool, + #[arg(long, global = true, action = ArgAction::SetTrue, conflicts_with = "headless")] + pub(crate) headed: bool, + #[arg(long, global = true, hide = true, action = ArgAction::SetTrue)] + pub(crate) automation: bool, + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub(crate) elevate: bool, + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Commands { + Package { + manifest: PathBuf, + #[arg(long, default_value = "dist")] + output: PathBuf, + }, + Install { + manifest: PathBuf, + #[arg(long)] + journal: Option, + }, + Uninstall { + journal: PathBuf, + }, + #[command(hide = true)] + Cleanup { + #[arg(long)] + target_exe: PathBuf, + #[arg(long)] + install_root: Option, + #[arg(long)] + app_name: String, + }, +} + +#[derive(Clone, Copy)] +pub(crate) struct UiPreferences { + pub(crate) json: bool, + pub(crate) headless: bool, + pub(crate) headed: bool, + pub(crate) automation: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UiMode { + None, + Gui, + Tui, +} + +pub(crate) fn error_exit_code(err: &AppError) -> i32 { + match err { + AppError::Cancelled | AppError::CancelledByUser => EXIT_CANCELLED, + AppError::Message(message) if message.contains("Elevation required") => { + EXIT_ELEVATION_REQUIRED + } + _ => EXIT_OPERATION_FAILED, + } +} + +pub(crate) fn dispatch(cli: Cli, sys: &dyn Sys, logger: &Logger) -> Result<(), AppError> { + let preferences = ui_preferences_from_cli(&cli); + let command = cli.command.ok_or_else(|| { + AppError::Message( + "Missing command: expected package, install, uninstall, or cleanup".into(), + ) + })?; + match command { + Commands::Package { manifest, output } => package(&manifest, &output, logger), + Commands::Install { manifest, journal } => install( + &manifest, + journal, + cli.elevate, + select_ui(preferences, sys, logger)?, + preferences.automation, + sys, + None, + logger, + ), + Commands::Uninstall { journal } => uninstall( + &journal, + cli.elevate, + select_ui(preferences, sys, logger)?, + preferences.automation, + sys, + &mut None, + logger, + false, + ), + Commands::Cleanup { + target_exe, + install_root, + app_name, + } => cleanup( + target_exe, + install_root, + app_name, + select_ui(preferences, sys, logger)?, + preferences.automation, + sys, + logger, + ), + } +} + +pub(crate) fn ui_preferences_from_cli(cli: &Cli) -> UiPreferences { + UiPreferences { + json: cli.json, + headless: cli.headless, + headed: cli.headed, + automation: cli.automation, + } +} + +pub(crate) fn select_ui( + preferences: UiPreferences, + sys: &dyn Sys, + logger: &Logger, +) -> Result { + if preferences.json { + return Ok(UiMode::None); + } + if preferences.headless && preferences.headed { + return Err(AppError::Message( + "Pass either --headed or --headless, not both".into(), + )); + } + if preferences.headless { + return Ok(UiMode::Tui); + } + if preferences.headed { + if !sys.ui_available() { + logger.info("gui_unavailable_fallback", json!({"fallback": "headless"})); + return Ok(UiMode::Tui); + } + return Ok(UiMode::Gui); + } + Err(AppError::Message( + "UI mode is required. Pass --headed for the WinForms UI, --headless for terminal progress, or --json for machine-readable output".into(), + )) +} + +pub(crate) fn ui_mode_name(ui_mode: UiMode) -> &'static str { + match ui_mode { + UiMode::None => "none", + UiMode::Gui => "gui", + UiMode::Tui => "tui", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + use crate::*; + + #[test] + fn cli_parses_bundled_flags_without_manual_preparse() { + let bundled = Cli::try_parse_from(["setup.exe", "--headed", "--automation"]).unwrap(); + assert!(bundled.command.is_none()); + assert!(bundled.headed); + assert!(bundled.automation); + + let direct = + Cli::try_parse_from(["setup.exe", "--headless", "install", "manifest.toml"]).unwrap(); + assert!(matches!(direct.command, Some(Commands::Install { .. }))); + assert!(direct.headless); + } + + #[test] + fn run_without_command_reports_missing_command() { + let cli = Cli::try_parse_from(["setup.exe", "--headless"]).unwrap(); + let err = dispatch(cli, &WinSys, &quiet_logger()) + .unwrap_err() + .to_string(); + + assert!(err.contains("Missing command")); + } + + #[test] + fn run_package_command_creates_packaged_installer() { + let temp = TestDir::new("run-package"); + let manifest = temp.path().join("RunPackage-install.toml"); + let output = temp.path().join("dist"); + fs::write(&manifest, "app_name = 'Run Package'\n").unwrap(); + + let cli = Cli::try_parse_from([ + OsString::from("setup.exe"), + OsString::from("--json"), + OsString::from("package"), + manifest.clone().into_os_string(), + OsString::from("--output"), + output.clone().into_os_string(), + ]) + .unwrap(); + dispatch(cli, &WinSys, &quiet_logger()).unwrap(); + + let installer = output.join("covenant-setup-installer.exe"); + assert!(installer.is_file()); + assert_eq!( + read_embedded_bundle(&installer) + .unwrap() + .unwrap() + .metadata + .app_name, + "Run Package" + ); + } + + #[test] + fn ui_preferences_and_selection_cover_explicit_modes() { + let json_cli = Cli::try_parse_from(["setup.exe", "--json", "--headed"]).unwrap(); + let json_preferences = ui_preferences_from_cli(&json_cli); + assert!(json_preferences.json); + assert_eq!( + select_ui(json_preferences, &WinSys, &quiet_logger()).unwrap(), + UiMode::None + ); + + let headless_cli = Cli::try_parse_from(["setup.exe", "--headless"]).unwrap(); + assert_eq!( + select_ui( + ui_preferences_from_cli(&headless_cli), + &WinSys, + &quiet_logger() + ) + .unwrap(), + UiMode::Tui + ); + + let missing = UiPreferences { + json: false, + headless: false, + headed: false, + automation: false, + }; + assert!(select_ui(missing, &WinSys, &quiet_logger()).is_err()); + + let conflict = UiPreferences { + json: false, + headless: true, + headed: true, + automation: false, + }; + assert!(select_ui(conflict, &WinSys, &quiet_logger()).is_err()); + } + + #[test] + fn headed_selection_uses_gui_or_documented_fallback() { + let mode = select_ui( + UiPreferences { + json: false, + headless: false, + headed: true, + automation: false, + }, + &WinSys, + &quiet_logger(), + ) + .unwrap(); + + assert!(matches!(mode, UiMode::Gui | UiMode::Tui)); + } + + #[test] + fn ui_mode_names_are_stable() { + assert_eq!(ui_mode_name(UiMode::None), "none"); + assert_eq!(ui_mode_name(UiMode::Gui), "gui"); + assert_eq!(ui_mode_name(UiMode::Tui), "tui"); + } + + #[test] + fn error_exit_code_maps_cancelled_elevation_and_default() { + assert_eq!(error_exit_code(&AppError::Cancelled), EXIT_CANCELLED); + assert_eq!(error_exit_code(&AppError::CancelledByUser), EXIT_CANCELLED); + assert_eq!( + error_exit_code(&AppError::Message( + "Elevation required to write to HKLM".into() + )), + EXIT_ELEVATION_REQUIRED + ); + assert_eq!( + error_exit_code(&AppError::Message("boom".into())), + EXIT_OPERATION_FAILED + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..542676d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,24 @@ +use std::io; +use thiserror::Error; + +pub(crate) const EXIT_ELEVATION_REQUIRED: i32 = 33; +pub(crate) const EXIT_OPERATION_FAILED: i32 = 1; +pub(crate) const EXIT_CANCELLED: i32 = 40; + +#[derive(Debug, Error)] +pub(crate) enum AppError { + #[error("{0}")] + Message(String), + #[error("Installation cancelled; changes were reverted")] + Cancelled, + #[error("Installation cancelled by user")] + CancelledByUser, + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Toml(#[from] toml::de::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Windows(#[from] windows::core::Error), +} diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..a96cc2b --- /dev/null +++ b/src/install.rs @@ -0,0 +1,1353 @@ +use crate::sys::Sys; +use crate::*; +use serde_json::json; +use std::fs; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone)] +pub(crate) struct InstallRuntime { + pub(crate) journal_path: PathBuf, + pub(crate) install_root: Option, + pub(crate) uninstall_exe_path: Option, + pub(crate) uninstall_registry_root: RegistryRoot, + pub(crate) uninstall_registry_key: String, +} +pub(crate) fn install( + manifest_path: &Path, + journal_path: Option, + elevate: bool, + ui_mode: UiMode, + automation: bool, + sys: &dyn Sys, + progress_override: Option>, + logger: &Logger, +) -> Result<(), AppError> { + let manifest = read_install_manifest(manifest_path)?; + trace_event( + "install_start", + json!({"manifest": manifest_path, "app_name": &manifest.app_name}), + ); + let app_name = manifest.app_name.clone(); + let _progress = start_tui_progress(ui_mode, format!("Installing {} ", manifest.app_name)); + let effective_logger = if ui_mode == UiMode::Tui { + logger.quiet_clone() + } else { + logger.clone() + }; + let resolver = win::PathResolver::new(&effective_logger)?; + let install_total = total_install_steps(&manifest); + let mut gui_progress = if progress_override.is_some() { + progress_override + } else { + let install_root = infer_install_root(&manifest, &resolver); + // Branding is only consumed by the GUI welcome/progress window, so skip + // the filesystem probe entirely for TUI/JSON/headless/automation runs. + let branding_image = if ui_mode == UiMode::Gui { + find_branding_image(manifest_path.parent(), install_root.as_deref()) + } else { + None + }; + let show_welcome = !automation && !manifest.app_name.trim().is_empty(); + start_gui_progress( + ui_mode, + sys, + &format!("Installing {}", manifest.app_name), + Some(&manifest.app_name), + install_root.as_deref(), + branding_image.as_deref(), + show_welcome, + install_total, + &effective_logger, + )? + }; + let mut tracker = DeclaredTracker::new(); + let mut runtime_opt: Option = None; + let result = (|| -> Result<(), AppError> { + let requires_admin = manifest_requires_admin(&manifest, &resolver)?; + ensure_elevation_if_needed(requires_admin, elevate, sys, &effective_logger)?; + trace_event( + "install_elevation_checked", + json!({"requires_admin": requires_admin, "elevate": elevate}), + ); + let runtime = build_install_runtime( + &manifest, + manifest_path, + journal_path, + requires_admin, + &resolver, + )?; + runtime_opt = Some(runtime.clone()); + + let mut progress_step = 0usize; + for directory in &manifest.directories { + let path = resolver.resolve(directory); + effective_logger.info("create_directory", json!({"path":path})); + advance_gui_progress_step( + &mut gui_progress, + &mut progress_step, + install_total, + &format!("Creating directory {}", path.display()), + )?; + win::create_directory_recursive(&path, &effective_logger)?; + tracker.record(JournalAction::CreateDirectory { path }); + } + + for file in &manifest.files { + let source = absolutize(manifest_path.parent(), &file.source); + let destination = resolver.resolve(&file.destination); + if let Some(parent) = destination.parent() { + win::create_directory_recursive(parent, &effective_logger)?; + } + effective_logger.info( + "copy_file", + json!({"source":source,"destination":destination}), + ); + advance_gui_progress_step( + &mut gui_progress, + &mut progress_step, + install_total, + &format!("Copying file to {}", destination.display()), + )?; + win::copy_file(&source, &destination, &effective_logger)?; + tracker.record(JournalAction::CopyFile { + source, + destination, + }); + } + + for entry in &manifest.registry { + let (root, subkey) = parse_registry_key(&entry.key)?; + let resolved_value = resolver.resolve(&entry.value).to_string_lossy().to_string(); + effective_logger.info( + "write_registry", + json!({"key":entry.key,"name":entry.name,"value":resolved_value}), + ); + advance_gui_progress_step( + &mut gui_progress, + &mut progress_step, + install_total, + &format!("Writing registry value {} in {}", entry.name, entry.key), + )?; + sys.set_registry_string( + root, + &subkey, + &entry.name, + &resolved_value, + &effective_logger, + )?; + tracker.record(JournalAction::WriteRegistry { + root, + subkey, + name: entry.name.clone(), + }); + } + + for shortcut in &manifest.shortcuts { + let path = resolver.resolve(&shortcut.path); + let target = resolver.resolve(&shortcut.target); + let working_directory = shortcut + .working_directory + .as_deref() + .map(|v| resolver.resolve(v)); + if let Some(parent) = path.parent() { + win::create_directory_recursive(parent, &effective_logger)?; + } + effective_logger.info("create_shortcut", json!({"path":path,"target":target})); + advance_gui_progress_step( + &mut gui_progress, + &mut progress_step, + install_total, + &format!("Creating shortcut {}", path.display()), + )?; + win::create_shortcut( + &path, + &target, + shortcut.arguments.as_deref(), + working_directory.as_deref(), + shortcut.description.as_deref(), + &effective_logger, + )?; + tracker.record(JournalAction::CreateShortcut { path }); + } + + for script in &manifest.scripts { + let working_directory = script + .working_directory + .as_deref() + .map(|v| resolver.resolve(v)); + effective_logger.info("execute_script", json!({"command":script.command,"args":script.args,"working_directory":working_directory})); + advance_gui_progress_step( + &mut gui_progress, + &mut progress_step, + install_total, + &format!("Running script {}", script.command), + )?; + execute_script( + script, + manifest_path.parent(), + working_directory.as_deref(), + &mut gui_progress, + )?; + tracker.record(JournalAction::ExecuteScript { + command: script.command.clone(), + args: script.args.clone(), + working_directory, + }); + } + + if let Some(uninstall_exe_path) = &runtime.uninstall_exe_path { + advance_gui_progress_step( + &mut gui_progress, + &mut progress_step, + install_total, + &format!("Installing uninstaller {}", uninstall_exe_path.display()), + )?; + install_uninstaller(uninstall_exe_path, &effective_logger)?; + tracker.record(JournalAction::CopyFile { + source: std::env::current_exe()?, + destination: uninstall_exe_path.clone(), + }); + } + + if let (Some(install_root), Some(uninstall_exe_path)) = + (&runtime.install_root, &runtime.uninstall_exe_path) + { + advance_gui_progress_step( + &mut gui_progress, + &mut progress_step, + install_total, + &format!("Registering {} in Installed Apps", manifest.app_name), + )?; + register_uninstall_entry( + &manifest, + &runtime, + install_root, + uninstall_exe_path, + sys, + &effective_logger, + )?; + for value_name in [ + "DisplayName", + "Publisher", + "DisplayVersion", + "InstallLocation", + "DisplayIcon", + "UninstallString", + "QuietUninstallString", + ] { + tracker.record(JournalAction::WriteRegistry { + root: runtime.uninstall_registry_root, + subkey: runtime.uninstall_registry_key.clone(), + name: value_name.to_string(), + }); + } + } + + Ok(()) + })(); + + let journal = tracker.finish( + manifest.app_name.clone(), + Some(manifest_path.to_path_buf()), + manifest.purge.clone(), + manifest.support_contact.clone(), + ); + let result = result.and_then(|_| { + let runtime = runtime_opt + .as_ref() + .ok_or_else(|| AppError::Message("Install runtime was not initialized".to_string()))?; + write_journal(&runtime.journal_path, &journal)?; + trace_event( + "install_journal_written", + json!({"journal": runtime.journal_path, "actions": journal.actions.len()}), + ); + effective_logger.result( + "ok", + json!({"journal":runtime.journal_path,"actions":journal.actions.len()}), + ); + finish_gui_progress( + &mut gui_progress, + &format!("{} installation completed successfully", manifest.app_name), + !automation, + )?; + if ui_mode == UiMode::Tui { + println!("{} installation completed successfully", manifest.app_name); + } + Ok(()) + }); + + result.map_err(|err| { + trace_event( + "install_error", + json!({"app_name": app_name, "error": err.to_string()}), + ); + let cancelled = matches!(err, AppError::Cancelled); + let mut rollback_error_msg = None; + if let Some(runtime) = &runtime_opt { + if !journal.actions.is_empty() { + let write_res = (|| -> Result<(), AppError> { + write_journal(&runtime.journal_path, &journal)?; + let read_back = fs::read_to_string(&runtime.journal_path)?; + let _: Journal = serde_json::from_str(&read_back)?; + Ok(()) + })(); + match write_res { + Ok(()) => { + if cancelled && let Some(progress) = gui_progress.as_mut() { + // The flag already tripped the install loop; clear it + // so the rollback's own progress is not cancelled too. + progress.clear_cancel_request(); + let _ = progress.log("Cancel requested - reverting changes..."); + } + match uninstall( + &runtime.journal_path, + elevate, + ui_mode, + automation, + sys, + &mut gui_progress, + logger, + true, + ) { + Ok(()) => { + trace_event( + "rollback_success", + json!({"journal": runtime.journal_path, "actions": journal.actions.len()}), + ); + } + Err(u_err) => { + rollback_error_msg = Some(format!("Rollback failed: {u_err}")); + } + } + } + Err(w_err) => { + rollback_error_msg = + Some(format!("Could not write or verify journal.json: {w_err}")); + } + } + } + } + + if cancelled && rollback_error_msg.is_none() { + let _ = finish_gui_progress( + &mut gui_progress, + &format!("{app_name} installation cancelled. All changes were reverted."), + !automation, + ); + return AppError::Cancelled; + } + + let display_err = if let Some(r_err) = rollback_error_msg { + AppError::Message(format!( + "Installation failed: {err}. Additionally, rollback failed: {r_err}" + )) + } else { + err + }; + + let _ = fail_gui_progress( + &mut gui_progress, + &app_name, + "install", + &display_err, + manifest.support_contact.as_deref(), + !automation, + ); + display_err + }) +} + +pub(crate) fn ensure_elevation_if_needed( + required: bool, + relaunch: bool, + sys: &dyn Sys, + logger: &Logger, +) -> Result<(), AppError> { + if !required || sys.is_elevated(logger)? { + trace_event( + "elevation_ok", + json!({"required": required, "relaunch": relaunch}), + ); + return Ok(()); + } + if relaunch { + trace_event("elevation_relaunch", json!({})); + sys.relaunch_as_admin(logger)?; + return Err(AppError::Message("__elevated_relaunch__".into())); + } + trace_event("elevation_required_error", json!({})); + Err(AppError::Message( + "Elevation required for requested operation".into(), + )) +} + +pub(crate) fn build_install_runtime( + manifest: &InstallManifest, + manifest_path: &Path, + journal_path: Option, + requires_admin: bool, + resolver: &win::PathResolver, +) -> Result { + let install_root = infer_install_root(manifest, resolver); + let journal_path = journal_path.unwrap_or_else(|| { + install_root + .clone() + .unwrap_or_else(|| { + manifest_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf() + }) + .join("journal.json") + }); + let uninstall_exe_path = install_root + .clone() + .map(|root| root.join("covenant-setup-uninstall.exe")); + let uninstall_registry_root = if requires_admin { + RegistryRoot::Hklm + } else { + RegistryRoot::Hkcu + }; + let uninstall_registry_key = format!( + "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}", + sanitize_registry_component(&manifest.app_name) + ); + Ok(InstallRuntime { + journal_path, + install_root, + uninstall_exe_path, + uninstall_registry_root, + uninstall_registry_key, + }) +} + +pub(crate) fn infer_install_root( + manifest: &InstallManifest, + resolver: &win::PathResolver, +) -> Option { + if let Some(path) = manifest.purge.paths.first() { + return Some(resolver.resolve(path)); + } + if let Some(directory) = manifest.directories.first() { + return Some(resolver.resolve(directory)); + } + if let Some(file) = manifest.files.first() { + return resolver + .resolve(&file.destination) + .parent() + .map(Path::to_path_buf); + } + None +} + +/// Branding ships with the payload, so the manifest directory is probed +/// first; the install root only exists on reinstalls over a previous tree. +pub(crate) fn find_branding_image( + manifest_dir: Option<&Path>, + install_root: Option<&Path>, +) -> Option { + const BRANDING_FILE_NAMES: [&str; 3] = ["branding.png", "banner.png", "logo.png"]; + manifest_dir + .into_iter() + .chain(install_root) + .flat_map(|root| BRANDING_FILE_NAMES.iter().map(move |name| root.join(name))) + .find(|path| path.is_file()) + .map(|path| path.to_string_lossy().into_owned()) +} + +pub(crate) fn install_uninstaller( + uninstall_exe_path: &Path, + logger: &Logger, +) -> Result<(), AppError> { + if let Some(parent) = uninstall_exe_path.parent() { + fs::create_dir_all(parent)?; + } + let current_exe = std::env::current_exe()?; + logger.info( + "install_uninstaller", + json!({"source":current_exe,"destination":uninstall_exe_path}), + ); + fs::copy(current_exe, uninstall_exe_path)?; + Ok(()) +} + +pub(crate) fn register_uninstall_entry( + manifest: &InstallManifest, + runtime: &InstallRuntime, + install_root: &Path, + uninstall_exe_path: &Path, + sys: &dyn Sys, + logger: &Logger, +) -> Result<(), AppError> { + let uninstall_command = format!( + "\"{}\" uninstall \"{}\" --elevate", + uninstall_exe_path.display(), + runtime.journal_path.display() + ); + let values = [ + ("DisplayName", manifest.app_name.clone()), + ("Publisher", "covenant-setup".to_string()), + ("DisplayVersion", env!("CARGO_PKG_VERSION").to_string()), + ( + "InstallLocation", + install_root.to_string_lossy().to_string(), + ), + ( + "DisplayIcon", + uninstall_exe_path.to_string_lossy().to_string(), + ), + ("UninstallString", uninstall_command.clone()), + ("QuietUninstallString", uninstall_command), + ]; + for (name, value) in values { + logger.info( + "register_uninstall_value", + json!({"key":runtime.uninstall_registry_key,"name":name,"value":value}), + ); + sys.set_registry_string( + runtime.uninstall_registry_root, + &runtime.uninstall_registry_key, + name, + &value, + logger, + )?; + } + Ok(()) +} + +pub(crate) fn total_install_steps(manifest: &InstallManifest) -> usize { + manifest.directories.len() + + manifest.files.len() + + manifest.registry.len() + + manifest.shortcuts.len() + + manifest.scripts.len() + + 2 +} + +pub(crate) fn manifest_requires_admin( + manifest: &InstallManifest, + resolver: &win::PathResolver, +) -> Result { + for directory in &manifest.directories { + if resolver.requires_admin(&resolver.resolve(directory)) { + return Ok(true); + } + } + for file in &manifest.files { + if resolver.requires_admin(&resolver.resolve(&file.destination)) { + return Ok(true); + } + } + for shortcut in &manifest.shortcuts { + if resolver.requires_admin(&resolver.resolve(&shortcut.path)) { + return Ok(true); + } + } + for key in &manifest.registry { + let (root, _) = parse_registry_key(&key.key)?; + if matches!(root, RegistryRoot::Hklm) { + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn execute_script( + script: &ScriptSpec, + manifest_dir: Option<&Path>, + working_directory: Option<&Path>, + gui_progress: &mut Option>, +) -> Result<(), AppError> { + trace_event( + "script_start", + json!({"command": &script.command, "args": &script.args, "working_directory": working_directory}), + ); + let command_path = absolutize(manifest_dir, &script.command); + let command = if command_path.exists() { + command_path + } else { + PathBuf::from(&script.command) + }; + let mut process = Command::new(command); + process.creation_flags(CREATE_NO_WINDOW); + process.args(&script.args); + if let Some(dir) = working_directory { + process.current_dir(dir); + } + let output = process.output()?; + append_gui_shell_output(gui_progress, &output.stdout)?; + append_gui_shell_output(gui_progress, &output.stderr)?; + let status = output.status; + trace_event( + "script_exit", + json!({"command": &script.command, "status": status.code()}), + ); + if !status.success() { + return Err(AppError::Message(format!( + "Script failed: {} ({status})", + script.command + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + + #[test] + fn find_branding_image_prefers_manifest_dir_over_install_root() { + let temp = TestDir::new("branding-image"); + let manifest_dir = temp.path().join("payload-src"); + let install_root = temp.path().join("install-root"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::create_dir_all(&install_root).unwrap(); + + assert_eq!( + find_branding_image(Some(&manifest_dir), Some(&install_root)), + None + ); + + fs::write(install_root.join("logo.png"), b"png").unwrap(); + assert_eq!( + find_branding_image(Some(&manifest_dir), Some(&install_root)), + Some(install_root.join("logo.png").to_string_lossy().into_owned()) + ); + + fs::write(manifest_dir.join("branding.png"), b"png").unwrap(); + assert_eq!( + find_branding_image(Some(&manifest_dir), Some(&install_root)), + Some( + manifest_dir + .join("branding.png") + .to_string_lossy() + .into_owned() + ) + ); + } + + #[test] + fn elevation_not_required_short_circuits_without_admin_probe() { + ensure_elevation_if_needed(false, false, &WinSys, &quiet_logger()).unwrap(); + } + + #[test] + fn total_step_helpers_count_manifest_and_journal_work() { + let manifest = sample_manifest(); + assert_eq!(total_install_steps(&manifest), 7); + + let journal = Journal { + app_name: "Steps".to_string(), + manifest_path: None, + actions: vec![ + JournalAction::CreateDirectory { + path: PathBuf::from("C:\\Apps\\Steps"), + }, + JournalAction::CreateShortcut { + path: PathBuf::from("C:\\Users\\Public\\Desktop\\Steps.lnk"), + }, + ], + purge: PurgeSpec { + registry_branches: vec!["HKCU\\Software\\Steps".to_string()], + paths: vec!["C:\\Apps\\Steps\\Cache".to_string()], + }, + support_contact: None, + }; + assert_eq!(total_uninstall_steps(&journal), 6); + } + + #[test] + fn install_empty_manifest_writes_minimal_journal() { + let temp = TestDir::new("install-empty"); + let manifest_path = temp.path().join("EmptyApp-install.toml"); + fs::write(&manifest_path, "app_name = 'Empty App'\n").unwrap(); + + install( + &manifest_path, + None, + false, + UiMode::None, + true, + &WinSys, + None, + &quiet_logger(), + ) + .unwrap(); + + let journal_path = temp.path().join("journal.json"); + let journal: Journal = + serde_json::from_str(&fs::read_to_string(journal_path).unwrap()).unwrap(); + assert_eq!(journal.app_name, "Empty App"); + assert_eq!(journal.manifest_path, Some(manifest_path)); + assert!(journal.actions.is_empty()); + assert_eq!(journal.purge, PurgeSpec::default()); + } + + #[test] + fn execute_script_reports_success_and_failure_status() { + let mut progress = None; + let ok = ScriptSpec { + command: "cmd.exe".to_string(), + args: vec!["/C".to_string(), "exit 0".to_string()], + working_directory: None, + }; + execute_script(&ok, None, None, &mut progress).unwrap(); + + let failing = ScriptSpec { + command: "cmd.exe".to_string(), + args: vec!["/C".to_string(), "exit 7".to_string()], + working_directory: None, + }; + let err = execute_script(&failing, None, None, &mut progress) + .unwrap_err() + .to_string(); + assert!(err.contains("Script failed: cmd.exe")); + } + + #[test] + fn install_uninstaller_copies_current_exe_to_target() { + let temp = TestDir::new("install-uninstaller"); + let target = temp.path().join("bin").join("covenant-setup-uninstall.exe"); + + install_uninstaller(&target, &quiet_logger()).unwrap(); + + assert!(target.is_file()); + assert!(fs::metadata(target).unwrap().len() > 0); + } + + #[test] + fn execute_script_uses_manifest_relative_command_and_working_directory() { + let temp = TestDir::new("script-relative"); + let script = temp.path().join("ok.cmd"); + let working_directory = temp.path().join("wd"); + fs::create_dir_all(&working_directory).unwrap(); + fs::write(&script, "@echo off\r\ncd\r\nexit /B 0\r\n").unwrap(); + + let spec = ScriptSpec { + command: "ok.cmd".to_string(), + args: Vec::new(), + working_directory: None, + }; + let mut progress = None; + + execute_script( + &spec, + Some(temp.path()), + Some(&working_directory), + &mut progress, + ) + .unwrap(); + } + + #[test] + fn install_runtime_uses_inferred_or_explicit_journal_paths() { + let resolver = + win::PathResolver::with_roots_for_test(vec![PathBuf::from("C:\\Program Files")]); + let manifest = sample_manifest(); + let runtime = build_install_runtime( + &manifest, + Path::new("C:\\source\\install.toml"), + None, + true, + &resolver, + ) + .unwrap(); + + assert_eq!( + runtime.journal_path, + PathBuf::from("C:\\Apps\\Sample\\journal.json") + ); + assert_eq!( + runtime.uninstall_exe_path, + Some(PathBuf::from( + "C:\\Apps\\Sample\\covenant-setup-uninstall.exe" + )) + ); + assert_eq!(runtime.uninstall_registry_root, RegistryRoot::Hklm); + assert!(runtime.uninstall_registry_key.ends_with("Sample_App")); + + let explicit = build_install_runtime( + &manifest, + Path::new("C:\\source\\install.toml"), + Some(PathBuf::from("D:\\journal.json")), + false, + &resolver, + ) + .unwrap(); + assert_eq!(explicit.journal_path, PathBuf::from("D:\\journal.json")); + assert_eq!(explicit.uninstall_registry_root, RegistryRoot::Hkcu); + } + + #[test] + fn infer_install_root_prefers_purge_then_directory_then_file_parent() { + let resolver = win::PathResolver::with_roots_for_test(vec![]); + let mut manifest = sample_manifest(); + + assert_eq!( + infer_install_root(&manifest, &resolver), + Some(PathBuf::from("C:\\Apps\\Sample")) + ); + + manifest.purge.paths.clear(); + assert_eq!( + infer_install_root(&manifest, &resolver), + Some(PathBuf::from("C:\\Apps\\Sample\\bin")) + ); + + manifest.directories.clear(); + assert_eq!( + infer_install_root(&manifest, &resolver), + Some(PathBuf::from("C:\\Apps\\Sample")) + ); + + manifest.files.clear(); + assert_eq!(infer_install_root(&manifest, &resolver), None); + } + + #[test] + fn manifest_and_journal_admin_checks_use_resolved_paths_and_hklm() { + let resolver = + win::PathResolver::with_roots_for_test(vec![PathBuf::from("C:\\Program Files")]); + let mut manifest = sample_manifest(); + manifest.purge.paths = vec!["C:\\Users\\Alice\\App".to_string()]; + manifest.directories = vec!["C:\\Program Files\\Sample".to_string()]; + assert!(manifest_requires_admin(&manifest, &resolver).unwrap()); + + manifest.directories.clear(); + manifest.files.clear(); + manifest.shortcuts.clear(); + manifest.registry = vec![RegistrySpec { + key: "HKLM\\Software\\Sample".to_string(), + name: "Value".to_string(), + value: "Data".to_string(), + }]; + assert!(manifest_requires_admin(&manifest, &resolver).unwrap()); + + let journal = Journal { + app_name: "Admin".to_string(), + manifest_path: None, + actions: vec![JournalAction::CopyFile { + source: PathBuf::from("payload.exe"), + destination: PathBuf::from("C:\\Program Files\\Sample\\payload.exe"), + }], + purge: PurgeSpec::default(), + support_contact: None, + }; + assert!(journal_requires_admin(&journal, &resolver).unwrap()); + + let journal = Journal { + app_name: "Admin".to_string(), + manifest_path: None, + actions: vec![], + purge: PurgeSpec { + registry_branches: vec!["HKLM\\Software\\Sample".to_string()], + paths: vec![], + }, + support_contact: None, + }; + assert!(journal_requires_admin(&journal, &resolver).unwrap()); + } + + #[test] + fn manifest_and_journal_admin_checks_return_false_for_user_scope_work() { + let resolver = + win::PathResolver::with_roots_for_test(vec![PathBuf::from("C:\\Program Files")]); + let manifest = InstallManifest { + app_name: "User App".to_string(), + directories: vec!["C:\\Users\\Alice\\AppData\\Local\\UserApp".to_string()], + files: vec![FileSpec { + source: "app.exe".to_string(), + destination: "C:\\Users\\Alice\\AppData\\Local\\UserApp\\app.exe".to_string(), + }], + registry: vec![RegistrySpec { + key: "HKCU\\Software\\UserApp".to_string(), + name: "InstallLocation".to_string(), + value: "C:\\Users\\Alice\\AppData\\Local\\UserApp".to_string(), + }], + shortcuts: vec![ShortcutSpec { + path: "C:\\Users\\Alice\\Desktop\\UserApp.lnk".to_string(), + target: "C:\\Users\\Alice\\AppData\\Local\\UserApp\\app.exe".to_string(), + arguments: None, + working_directory: None, + description: None, + }], + scripts: Vec::new(), + purge: PurgeSpec { + registry_branches: vec!["HKCU\\Software\\UserApp".to_string()], + paths: vec!["C:\\Users\\Alice\\AppData\\Local\\UserApp".to_string()], + }, + support_contact: None, + }; + assert!(!manifest_requires_admin(&manifest, &resolver).unwrap()); + + let journal = Journal { + app_name: "User App".to_string(), + manifest_path: None, + actions: vec![ + JournalAction::CreateDirectory { + path: PathBuf::from("C:\\Users\\Alice\\AppData\\Local\\UserApp"), + }, + JournalAction::WriteRegistry { + root: RegistryRoot::Hkcu, + subkey: "Software\\UserApp".to_string(), + name: "InstallLocation".to_string(), + }, + ], + purge: manifest.purge, + support_contact: None, + }; + assert!(!journal_requires_admin(&journal, &resolver).unwrap()); + } + + #[test] + fn admin_checks_detect_file_shortcut_and_purge_paths() { + let resolver = + win::PathResolver::with_roots_for_test(vec![PathBuf::from("C:\\Program Files")]); + let mut manifest = sample_manifest(); + manifest.directories.clear(); + manifest.registry.clear(); + manifest.files = vec![FileSpec { + source: "payload.exe".to_string(), + destination: "C:\\Program Files\\Sample\\payload.exe".to_string(), + }]; + assert!(manifest_requires_admin(&manifest, &resolver).unwrap()); + + manifest.files.clear(); + manifest.shortcuts = vec![ShortcutSpec { + path: "C:\\Program Files\\Sample\\Sample.lnk".to_string(), + target: "C:\\Users\\Alice\\App\\app.exe".to_string(), + arguments: None, + working_directory: None, + description: None, + }]; + assert!(manifest_requires_admin(&manifest, &resolver).unwrap()); + + let journal = Journal { + app_name: "Admin".to_string(), + manifest_path: None, + actions: vec![JournalAction::CreateShortcut { + path: PathBuf::from("C:\\Program Files\\Sample\\Sample.lnk"), + }], + purge: PurgeSpec::default(), + support_contact: None, + }; + assert!(journal_requires_admin(&journal, &resolver).unwrap()); + + let journal = Journal { + app_name: "Admin".to_string(), + manifest_path: None, + actions: Vec::new(), + purge: PurgeSpec { + registry_branches: Vec::new(), + paths: vec!["C:\\Program Files\\Sample".to_string()], + }, + support_contact: None, + }; + assert!(journal_requires_admin(&journal, &resolver).unwrap()); + } + + // (a) + #[test] + fn ensure_elevation_if_needed_relaunches_when_required_and_relaunch_flag_set() { + let sys = MockSys::new(); + sys.set_is_elevated(false); + let err = ensure_elevation_if_needed(true, true, &sys, &quiet_logger()).unwrap_err(); + assert_eq!(err.to_string(), "__elevated_relaunch__"); + let calls = sys.recorded(); + assert!(matches!(calls[0], SysCall::IsElevated)); + assert!(matches!(calls[1], SysCall::RelaunchAsAdmin)); + } + + // (b) + #[test] + fn ensure_elevation_if_needed_errors_when_required_and_no_relaunch() { + let sys = MockSys::new(); + sys.set_is_elevated(false); + let err = ensure_elevation_if_needed(true, false, &sys, &quiet_logger()).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("Elevation required")); + let calls = sys.recorded(); + assert_eq!(calls.len(), 1); + assert!(matches!(calls[0], SysCall::IsElevated)); + } + + // (c) + #[test] + fn ensure_elevation_if_needed_passes_when_already_elevated() { + let sys = MockSys::new(); + sys.set_is_elevated(true); + ensure_elevation_if_needed(true, true, &sys, &quiet_logger()).unwrap(); + let calls = sys.recorded(); + assert_eq!(calls.len(), 1); + assert!(matches!(calls[0], SysCall::IsElevated)); + } + + // (d) + #[test] + fn register_uninstall_entry_writes_all_seven_values() { + let manifest = InstallManifest { + app_name: "Sample".to_string(), + directories: Vec::new(), + files: Vec::new(), + registry: Vec::new(), + shortcuts: Vec::new(), + scripts: Vec::new(), + purge: PurgeSpec::default(), + support_contact: None, + }; + let runtime = InstallRuntime { + journal_path: PathBuf::from("C:\\fake\\journal.json"), + install_root: Some(PathBuf::from("C:\\Apps\\Sample")), + uninstall_exe_path: Some(PathBuf::from("C:\\Apps\\Sample\\uninstall.exe")), + uninstall_registry_root: RegistryRoot::Hkcu, + uninstall_registry_key: + "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Sample".to_string(), + }; + let install_root = PathBuf::from("C:\\Apps\\Sample"); + let uninstall_exe = PathBuf::from("C:\\Apps\\Sample\\uninstall.exe"); + let sys = MockSys::new(); + register_uninstall_entry( + &manifest, + &runtime, + &install_root, + &uninstall_exe, + &sys, + &quiet_logger(), + ) + .unwrap(); + let writes: Vec<_> = sys + .recorded() + .into_iter() + .filter_map(|c| match c { + SysCall::SetRegistryString { name, .. } => Some(name), + _ => None, + }) + .collect(); + assert_eq!( + writes, + vec![ + "DisplayName", + "Publisher", + "DisplayVersion", + "InstallLocation", + "DisplayIcon", + "UninstallString", + "QuietUninstallString", + ] + ); + } + + // (h) — substitute: run() dispatches Install subcommand to install() through Sys. + #[test] + fn run_install_subcommand_uses_sys_for_registry_writes() { + let temp = TestDir::new("run-install-sys"); + let manifest_path = temp.path().join("Mocked-install.toml"); + fs::write( + &manifest_path, + "app_name = 'Mocked'\n[[registry]]\nkey = 'HKCU\\\\Software\\\\Mocked'\nname = 'Foo'\nvalue = 'Bar'\n", + ) + .unwrap(); + let journal_path = temp.path().join("journal.json"); + let cli = Cli { + json: true, + headed: false, + headless: false, + automation: true, + elevate: false, + command: Some(Commands::Install { + manifest: manifest_path.clone(), + journal: Some(journal_path), + }), + }; + let sys = MockSys::new(); + sys.set_is_elevated(true); + dispatch(cli, &sys, &quiet_logger()).unwrap(); + let writes: Vec<_> = sys + .recorded() + .into_iter() + .filter_map(|c| match c { + SysCall::SetRegistryString { name, value, .. } => Some((name, value)), + _ => None, + }) + .collect(); + assert!(writes.iter().any(|(n, v)| n == "Foo" && v == "Bar")); + } + + // (i) — substitute: install error path emits ui_report_error via run_bundled_installer + // is exercised at the install layer: a missing manifest yields AppError::Io and the + // automation flag suppresses the GUI fail UX. We assert the error propagates. + #[test] + fn install_with_missing_manifest_propagates_error_without_success_ui() { + let sys = MockSys::new(); + let err = install( + Path::new("C:\\does\\not\\exist\\install.toml"), + None, + false, + UiMode::None, + true, + &sys, + None, + &quiet_logger(), + ) + .unwrap_err(); + assert!(matches!(err, AppError::Io(_) | AppError::Message(_))); + } + + #[test] + fn install_failure_triggers_partial_rollback() { + let temp = TestDir::new("install-rollback-test"); + let manifest_path = temp.path().join("RollbackApp-install.toml"); + let dir_to_create = temp.path().join("CreatedDir"); + + // Manifest specifies creating a directory, then copying a non-existent file (which will fail) + fs::write( + &manifest_path, + format!( + "app_name = 'RollbackApp'\ndirectories = {{ paths = ['{}'] }}\n[[files]]\nsource = 'nonexistent.txt'\ndestination = '{}\\\\file.txt'\n", + dir_to_create.display().to_string().replace("\\", "\\\\"), + dir_to_create.display().to_string().replace("\\", "\\\\") + ), + ) + .unwrap(); + + let sys = MockSys::new(); + sys.set_is_elevated(true); + + let err = install( + &manifest_path, + None, + false, + UiMode::None, + true, // automation = true + &sys, + None, + &quiet_logger(), + ) + .unwrap_err(); + + // The installation must have failed + assert!( + err.to_string().contains("nonexistent.txt") + || err.to_string().contains("cannot find") + || err.to_string().contains("system cannot find") + ); + + // The directory created during the partial install must have been rolled back (deleted) + assert!(!dir_to_create.exists()); + } + + #[test] + fn install_cancel_triggers_rollback_and_returns_cancelled() { + let temp = TestDir::new("install-cancel-test"); + let manifest_path = temp.path().join("CancelApp-install.toml"); + let dir_to_create = temp.path().join("CreatedDir"); + let payload = temp.path().join("payload.txt"); + fs::write(&payload, b"data").unwrap(); + + fs::write( + &manifest_path, + format!( + "app_name = 'CancelApp'\ndirectories = {{ paths = ['{}'] }}\n[[files]]\nsource = '{}'\ndestination = '{}\\\\file.txt'\n", + dir_to_create.display().to_string().replace("\\", "\\\\"), + payload.display().to_string().replace("\\", "\\\\"), + dir_to_create.display().to_string().replace("\\", "\\\\") + ), + ) + .unwrap(); + + let sys = MockSys::new(); + sys.set_is_elevated(true); + let sink_calls = sys.install_progress_sink(); + // Trip the cancel flag once the first mutation step has been reported, + // as if the user clicked Cancel between steps. + sys.set_cancel_after_advances(1); + + let err = install( + &manifest_path, + None, + false, + UiMode::None, + false, + &sys, + None, + &quiet_logger(), + ) + .unwrap_err(); + + assert!(matches!(err, AppError::Cancelled)); + assert!(!dir_to_create.exists()); + + let calls = sink_calls.lock().unwrap().clone(); + assert!( + calls + .iter() + .any(|c| matches!(c, SinkCall::Log(m) if m.contains("Cancel requested"))) + ); + assert!(calls.iter().any( + |c| matches!(c, SinkCall::Advance { message, .. } if message.contains("Removing directory")) + )); + let finishes: Vec<_> = calls + .iter() + .filter_map(|c| match c { + SinkCall::Finish { + message, + wait_for_close, + } => Some((message.clone(), *wait_for_close)), + _ => None, + }) + .collect(); + assert_eq!(finishes.len(), 1); + assert!(finishes[0].0.contains("cancelled")); + assert!(finishes[0].1, "non-automation finish must wait for close"); + assert!(!calls.iter().any(|c| matches!(c, SinkCall::Fail { .. }))); + } + + #[test] + fn install_failure_rolls_back_through_same_sink_without_second_session() { + let temp = TestDir::new("install-rollback-same-sink"); + let manifest_path = temp.path().join("SameSinkApp-install.toml"); + let dir_to_create = temp.path().join("CreatedDir"); + + fs::write( + &manifest_path, + format!( + "app_name = 'SameSinkApp'\ndirectories = {{ paths = ['{}'] }}\n[[files]]\nsource = 'nonexistent.txt'\ndestination = '{}\\\\file.txt'\n", + dir_to_create.display().to_string().replace("\\", "\\\\"), + dir_to_create.display().to_string().replace("\\", "\\\\") + ), + ) + .unwrap(); + + let sys = MockSys::new(); + sys.set_is_elevated(true); + let sink_calls = sys.install_progress_sink(); + + let err = install( + &manifest_path, + None, + false, + UiMode::None, + true, + &sys, + None, + &quiet_logger(), + ) + .unwrap_err(); + assert!(!matches!(err, AppError::Cancelled)); + assert!(!dir_to_create.exists()); + + // The rollback must reuse the live session instead of starting a second one. + let start_progress_calls = sys + .recorded() + .iter() + .filter(|c| matches!(c, SysCall::StartProgress { .. })) + .count(); + assert_eq!(start_progress_calls, 1); + + let calls = sink_calls.lock().unwrap().clone(); + assert!(calls.iter().any( + |c| matches!(c, SinkCall::Advance { message, .. } if message.contains("Removing directory")) + )); + let fails = calls + .iter() + .filter(|c| matches!(c, SinkCall::Fail { .. })) + .count(); + assert_eq!(fails, 1, "exactly one terminal fail message"); + assert!(!calls.iter().any(|c| matches!(c, SinkCall::Finish { .. }))); + } + + #[test] + fn install_success_finish_wait_for_close_follows_automation_flag() { + for automation in [false, true] { + let temp = TestDir::new(if automation { + "install-finish-wait-auto" + } else { + "install-finish-wait-manual" + }); + let manifest_path = temp.path().join("FinishApp-install.toml"); + let dir_to_create = temp.path().join("CreatedDir"); + fs::write( + &manifest_path, + format!( + "app_name = 'FinishApp'\ndirectories = {{ paths = ['{}'] }}\n", + dir_to_create.display().to_string().replace("\\", "\\\\") + ), + ) + .unwrap(); + + let sys = MockSys::new(); + sys.set_is_elevated(true); + let sink_calls = sys.install_progress_sink(); + + install( + &manifest_path, + None, + false, + UiMode::None, + automation, + &sys, + None, + &quiet_logger(), + ) + .unwrap(); + + let calls = sink_calls.lock().unwrap().clone(); + let wait_for_close = calls + .iter() + .find_map(|c| match c { + SinkCall::Finish { wait_for_close, .. } => Some(*wait_for_close), + _ => None, + }) + .expect("finish recorded"); + assert_eq!(wait_for_close, !automation); + } + } + + #[test] + fn install_emits_set_registry_string_calls_for_each_registry_spec() { + let temp = TestDir::new("install-registry-mock"); + let manifest_path = temp.path().join("RegApp-install.toml"); + fs::write( + &manifest_path, + "app_name = 'RegApp'\n[[registry]]\nkey = 'HKCU\\\\Software\\\\RegApp'\nname = 'Alpha'\nvalue = 'A'\n[[registry]]\nkey = 'HKCU\\\\Software\\\\RegApp'\nname = 'Beta'\nvalue = 'B'\n", + ) + .unwrap(); + let sys = MockSys::new(); + sys.set_is_elevated(true); + install( + &manifest_path, + None, + false, + UiMode::None, + true, + &sys, + None, + &quiet_logger(), + ) + .unwrap(); + let manifest_writes: Vec<_> = sys + .recorded() + .into_iter() + .filter_map(|c| match c { + SysCall::SetRegistryString { name, value, .. } + if name == "Alpha" || name == "Beta" => + { + Some((name, value)) + } + _ => None, + }) + .collect(); + assert_eq!(manifest_writes.len(), 2); + assert!( + manifest_writes + .iter() + .any(|(n, v)| n == "Alpha" && v == "A") + ); + assert!(manifest_writes.iter().any(|(n, v)| n == "Beta" && v == "B")); + } + + // (k) +} diff --git a/src/journal.rs b/src/journal.rs new file mode 100644 index 0000000..dd08688 --- /dev/null +++ b/src/journal.rs @@ -0,0 +1,170 @@ +use crate::AppError; +use crate::manifest::PurgeSpec; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Journal { + pub(crate) app_name: String, + pub(crate) manifest_path: Option, + pub(crate) actions: Vec, + pub(crate) purge: PurgeSpec, + #[serde(default)] + pub(crate) support_contact: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum JournalAction { + CreateDirectory { + path: PathBuf, + }, + CopyFile { + source: PathBuf, + destination: PathBuf, + }, + WriteRegistry { + root: RegistryRoot, + subkey: String, + name: String, + }, + CreateShortcut { + path: PathBuf, + }, + ExecuteScript { + command: String, + args: Vec, + working_directory: Option, + }, +} + +pub(crate) trait MutationTracker { + fn record(&mut self, action: JournalAction); + fn finish( + self, + app_name: String, + manifest_path: Option, + purge: PurgeSpec, + support_contact: Option, + ) -> Journal; +} + +pub(crate) struct DeclaredTracker { + actions: Vec, +} + +impl DeclaredTracker { + pub(crate) fn new() -> Self { + Self { + actions: Vec::new(), + } + } +} + +impl MutationTracker for DeclaredTracker { + fn record(&mut self, action: JournalAction) { + self.actions.push(action); + } + + fn finish( + self, + app_name: String, + manifest_path: Option, + purge: PurgeSpec, + support_contact: Option, + ) -> Journal { + Journal { + app_name, + manifest_path, + actions: self.actions, + purge, + support_contact, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RegistryRoot { + Hkcu, + Hklm, +} + +pub(crate) fn write_journal(path: &Path, journal: &Journal) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, serde_json::to_vec_pretty(journal)?)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::*; + + #[test] + fn declared_tracker_records_actions_and_finishes_journal() { + let mut tracker = DeclaredTracker::new(); + tracker.record(JournalAction::CreateDirectory { + path: PathBuf::from("C:\\Apps\\Tracked"), + }); + + let journal = tracker.finish( + "Tracked App".to_string(), + Some(PathBuf::from("install.toml")), + PurgeSpec { + registry_branches: vec!["HKCU\\Software\\Tracked".to_string()], + paths: vec!["C:\\Apps\\Tracked".to_string()], + }, + None, + ); + + assert_eq!(journal.app_name, "Tracked App"); + assert_eq!(journal.actions.len(), 1); + assert_eq!(journal.purge.paths, vec!["C:\\Apps\\Tracked"]); + } + + #[test] + fn journal_serde_round_trips_all_action_variants() { + let journal = Journal { + app_name: "Serde App".to_string(), + manifest_path: Some(PathBuf::from("C:\\install\\app.toml")), + actions: vec![ + JournalAction::CreateDirectory { + path: PathBuf::from("C:\\Apps\\Serde"), + }, + JournalAction::CopyFile { + source: PathBuf::from("payload\\app.exe"), + destination: PathBuf::from("C:\\Apps\\Serde\\app.exe"), + }, + JournalAction::WriteRegistry { + root: RegistryRoot::Hkcu, + subkey: "Software\\SerdeApp".to_string(), + name: "InstallLocation".to_string(), + }, + JournalAction::CreateShortcut { + path: PathBuf::from("C:\\Users\\Public\\Desktop\\Serde.lnk"), + }, + JournalAction::ExecuteScript { + command: "powershell.exe".to_string(), + args: vec!["-NoProfile".to_string(), "-File".to_string()], + working_directory: Some(PathBuf::from("C:\\Apps\\Serde")), + }, + ], + purge: PurgeSpec { + registry_branches: vec!["HKCU\\Software\\Serde".to_string()], + paths: vec!["C:\\Apps\\Serde\\cache".to_string()], + }, + support_contact: None, + }; + + let serialized = serde_json::to_string_pretty(&journal).unwrap(); + let decoded: Journal = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(decoded, journal); + assert!(serialized.contains("\"type\": \"create_directory\"")); + assert!(serialized.contains("\"root\": \"hkcu\"")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..db4f738 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,85 @@ +mod bundle; +mod cli; +mod error; +mod install; +mod journal; +mod logging; +mod manifest; +mod progress; +mod sys; +mod ui; +mod uninstall; +mod util; +mod win; + +#[cfg(test)] +mod test_support; + +pub(crate) use bundle::*; +pub(crate) use cli::*; +pub(crate) use error::{AppError, EXIT_CANCELLED, EXIT_ELEVATION_REQUIRED, EXIT_OPERATION_FAILED}; +pub(crate) use install::*; +pub(crate) use journal::*; +pub(crate) use logging::{Logger, trace_event}; +pub(crate) use manifest::*; +pub(crate) use progress::*; +pub(crate) use uninstall::*; +pub(crate) use util::*; + +use clap::Parser; +use serde_json::json; +use std::ffi::OsString; +use std::process; +use std::thread; +use std::time::Duration; +use sys::{Sys, WinSys}; +use ui::ProgressSink; + +pub fn run() -> i32 { + let args: Vec<_> = std::env::args_os().collect(); + trace_event( + "process_start", + json!({ + "pid": process::id(), + "args": args.iter().map(|arg| arg.to_string_lossy().to_string()).collect::>() + }), + ); + let cli = Cli::parse(); + let logger = Logger { + json: cli.json, + quiet: false, + }; + let preferences = ui_preferences_from_cli(&cli); + let sys = WinSys; + if cli.command.is_none() && sys.has_embedded_bundle() { + let exit_code = match run_bundled_installer(preferences, &sys, &logger) { + Ok(()) => 0, + Err(AppError::Message(ref message)) if message == "__elevated_relaunch__" => 0, + Err(err) => { + if preferences.headed + && !preferences.automation + && !preferences.json + && sys.ui_available() + && !failure_ux_shown() + && !matches!(err, AppError::Cancelled | AppError::CancelledByUser) + { + let _ = sys.ui_report_error(&err.to_string()); + } + let code = error_exit_code(&err); + logger.error(err, code); + code + } + }; + return exit_code; + } + + match dispatch(cli, &sys, &logger) { + Ok(()) => 0, + Err(AppError::Message(message)) if message == "__elevated_relaunch__" => 0, + Err(err) => { + let code = error_exit_code(&err); + logger.error(err, code); + code + } + } +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..9c84c43 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,130 @@ +use serde::Serialize; +use serde_json::json; +use std::fmt::Display; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process; + +#[derive(Clone)] +pub(crate) struct Logger { + pub(crate) json: bool, + pub(crate) quiet: bool, +} + +impl Logger { + pub(crate) fn info(&self, event: &str, detail: impl Serialize) { + if self.quiet { + return; + } + if self.json { + println!("{}", json!({"type":"event","event":event,"detail":detail})); + } else { + println!( + "{event}: {}", + serde_json::to_string(&detail).unwrap_or_default() + ); + } + } + + pub(crate) fn unsafe_enter(&self, operation: &str, detail: impl Serialize) { + self.info( + "unsafe_enter", + json!({"operation":operation,"detail":detail}), + ); + } + + pub(crate) fn unsafe_exit(&self, operation: &str, detail: impl Serialize) { + self.info( + "unsafe_exit", + json!({"operation":operation,"detail":detail}), + ); + } + + pub(crate) fn result(&self, status: &str, detail: impl Serialize) { + if self.quiet { + return; + } + if self.json { + println!( + "{}", + json!({"type":"result","status":status,"detail":detail}) + ); + } else { + println!( + "{status}: {}", + serde_json::to_string(&detail).unwrap_or_default() + ); + } + } + + pub(crate) fn error(&self, message: impl Display, code: i32) { + if self.json { + println!( + "{}", + json!({"type":"error","code":code,"message":message.to_string()}) + ); + } else { + eprintln!("error[{code}]: {message}"); + } + } + + pub(crate) fn quiet_clone(&self) -> Self { + Self { + json: self.json, + quiet: true, + } + } +} + +pub(crate) fn trace_event(phase: &str, detail: impl Serialize) { + let Ok(root) = std::env::var("COVENANT_SETUP_TRACE_DIR") else { + return; + }; + if root.trim().is_empty() { + return; + } + + let root = PathBuf::from(root); + if fs::create_dir_all(&root).is_err() { + return; + } + let path = root.join(format!("installer-heartbeat-{}.jsonl", process::id())); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let event = json!({ + "time_unix_ms": timestamp, + "pid": process::id(), + "phase": phase, + "detail": detail + }); + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{event}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn logger_methods_and_quiet_clone_are_callable() { + for json_output in [false, true] { + let logger = Logger { + json: json_output, + quiet: false, + }; + logger.info("test_event", json!({"value": 1})); + logger.result("ok", json!({"value": 2})); + logger.error("boom", 7); + + let quiet = logger.quiet_clone(); + assert!(quiet.quiet); + assert_eq!(quiet.json, json_output); + quiet.info("hidden", json!({})); + quiet.result("hidden", json!({})); + } + } +} diff --git a/src/main.rs b/src/main.rs index fbac469..ebbdf83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4780 +1,5 @@ #![windows_subsystem = "windows"] -mod sys; -mod ui; -mod win; - -use clap::{ArgAction, Parser, Subcommand}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::ffi::OsString; -use std::fmt::Display; -use std::fs; -use std::io; -use std::io::{Read, Write}; -use std::os::windows::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::process::{self, Command}; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use sys::{Sys, WinSys}; -use thiserror::Error; -use ui::ProgressSink; - -const EXIT_ELEVATION_REQUIRED: i32 = 33; -const EXIT_OPERATION_FAILED: i32 = 1; -const EXIT_CANCELLED: i32 = 40; -const EMBEDDED_MAGIC: &[u8] = b"COVENANT_SETUP_BUNDLE_V1"; -const CREATE_NO_WINDOW: u32 = 0x0800_0000; -static FAILURE_UX_SHOWN: AtomicBool = AtomicBool::new(false); - -#[derive(Parser, Debug)] -#[command( - name = "covenant-setup", - version, - about = "Windows installer builder and engine" -)] -struct Cli { - #[arg(long, global = true, action = ArgAction::SetTrue)] - json: bool, - #[arg(long, global = true, action = ArgAction::SetTrue)] - headless: bool, - #[arg(long, global = true, action = ArgAction::SetTrue, conflicts_with = "headless")] - headed: bool, - #[arg(long, global = true, hide = true, action = ArgAction::SetTrue)] - automation: bool, - #[arg(long, global = true, action = ArgAction::SetTrue)] - elevate: bool, - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand, Debug)] -enum Commands { - Package { - manifest: PathBuf, - #[arg(long, default_value = "dist")] - output: PathBuf, - }, - Install { - manifest: PathBuf, - #[arg(long)] - journal: Option, - }, - Uninstall { - journal: PathBuf, - }, - #[command(hide = true)] - Cleanup { - #[arg(long)] - target_exe: PathBuf, - #[arg(long)] - install_root: Option, - #[arg(long)] - app_name: String, - }, -} - -#[derive(Debug, Deserialize)] -struct InstallManifest { - app_name: String, - #[serde(default, deserialize_with = "deserialize_directory_paths")] - directories: Vec, - #[serde(default)] - files: Vec, - #[serde(default)] - registry: Vec, - #[serde(default)] - shortcuts: Vec, - #[serde(default)] - scripts: Vec, - #[serde(default)] - purge: PurgeSpec, - #[serde(default)] - support_contact: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -struct PurgeSpec { - #[serde(default)] - registry_branches: Vec, - #[serde(default)] - paths: Vec, -} - -#[derive(Debug, Deserialize)] -struct DirectoryPaths { - #[serde(default)] - paths: Vec, -} - -fn deserialize_directory_paths<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - Ok(DirectoryPaths::deserialize(deserializer)?.paths) -} - -#[derive(Debug, Deserialize)] -struct FileSpec { - source: String, - destination: String, -} - -#[derive(Debug, Deserialize)] -struct RegistrySpec { - key: String, - name: String, - value: String, -} - -#[derive(Debug, Deserialize)] -struct ShortcutSpec { - path: String, - target: String, - #[serde(default)] - arguments: Option, - #[serde(default)] - working_directory: Option, - #[serde(default)] - description: Option, -} - -#[derive(Debug, Deserialize)] -struct ScriptSpec { - command: String, - #[serde(default)] - args: Vec, - #[serde(default)] - working_directory: Option, -} - -#[derive(Debug, Clone)] -struct InstallRuntime { - journal_path: PathBuf, - install_root: Option, - uninstall_exe_path: Option, - uninstall_registry_root: RegistryRoot, - uninstall_registry_key: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct Journal { - app_name: String, - manifest_path: Option, - actions: Vec, - purge: PurgeSpec, - #[serde(default)] - support_contact: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum JournalAction { - CreateDirectory { - path: PathBuf, - }, - CopyFile { - source: PathBuf, - destination: PathBuf, - }, - WriteRegistry { - root: RegistryRoot, - subkey: String, - name: String, - }, - CreateShortcut { - path: PathBuf, - }, - ExecuteScript { - command: String, - args: Vec, - working_directory: Option, - }, -} - -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] -struct PackagedApp { - app_name: String, - manifest: String, -} - -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] -struct EmbeddedFile { - relative_path: String, - data: Vec, -} - -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] -struct EmbeddedBundle { - metadata: PackagedApp, - files: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -struct EmbeddedBundleIndex { - metadata: PackagedApp, - files: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -struct EmbeddedFileIndexEntry { - relative_path: String, - len: u64, -} - -trait MutationTracker { - fn record(&mut self, action: JournalAction); - fn finish( - self, - app_name: String, - manifest_path: Option, - purge: PurgeSpec, - support_contact: Option, - ) -> Journal; -} - -struct DeclaredTracker { - actions: Vec, -} - -impl DeclaredTracker { - fn new() -> Self { - Self { - actions: Vec::new(), - } - } -} - -impl MutationTracker for DeclaredTracker { - fn record(&mut self, action: JournalAction) { - self.actions.push(action); - } - - fn finish( - self, - app_name: String, - manifest_path: Option, - purge: PurgeSpec, - support_contact: Option, - ) -> Journal { - Journal { - app_name, - manifest_path, - actions: self.actions, - purge, - support_contact, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum RegistryRoot { - Hkcu, - Hklm, -} - -#[derive(Debug, Error)] -enum AppError { - #[error("{0}")] - Message(String), - #[error("Installation cancelled; changes were reverted")] - Cancelled, - #[error("Installation cancelled by user")] - CancelledByUser, - #[error(transparent)] - Io(#[from] io::Error), - #[error(transparent)] - Toml(#[from] toml::de::Error), - #[error(transparent)] - Json(#[from] serde_json::Error), - #[error(transparent)] - Windows(#[from] windows::core::Error), -} - -#[derive(Clone)] -struct Logger { - json: bool, - quiet: bool, -} - -impl Logger { - fn info(&self, event: &str, detail: impl Serialize) { - if self.quiet { - return; - } - if self.json { - println!("{}", json!({"type":"event","event":event,"detail":detail})); - } else { - println!( - "{event}: {}", - serde_json::to_string(&detail).unwrap_or_default() - ); - } - } - - fn unsafe_enter(&self, operation: &str, detail: impl Serialize) { - self.info( - "unsafe_enter", - json!({"operation":operation,"detail":detail}), - ); - } - - fn unsafe_exit(&self, operation: &str, detail: impl Serialize) { - self.info( - "unsafe_exit", - json!({"operation":operation,"detail":detail}), - ); - } - - fn result(&self, status: &str, detail: impl Serialize) { - if self.quiet { - return; - } - if self.json { - println!( - "{}", - json!({"type":"result","status":status,"detail":detail}) - ); - } else { - println!( - "{status}: {}", - serde_json::to_string(&detail).unwrap_or_default() - ); - } - } - - fn error(&self, message: impl Display, code: i32) { - if self.json { - println!( - "{}", - json!({"type":"error","code":code,"message":message.to_string()}) - ); - } else { - eprintln!("error[{code}]: {message}"); - } - } - - fn quiet_clone(&self) -> Self { - Self { - json: self.json, - quiet: true, - } - } -} - -#[derive(Clone, Copy)] -struct UiPreferences { - json: bool, - headless: bool, - headed: bool, - automation: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UiMode { - None, - Gui, - Tui, -} - -struct TuiProgress { - active: Arc, - handle: Option>, -} - -impl TuiProgress { - fn start(label: String) -> Self { - let active = Arc::new(AtomicBool::new(true)); - let active_thread = active.clone(); - let handle = thread::spawn(move || { - let frames = ["", ".", "..", "...", "....", "....."]; - let mut index = 0usize; - while active_thread.load(Ordering::Relaxed) { - let frame = frames[index % frames.len()]; - print!("\r{label}{frame} "); - let _ = io::stdout().flush(); - thread::sleep(Duration::from_millis(500)); - index = (index + 1) % frames.len(); - } - print!("\r{}\r", " ".repeat(label.len() + 8)); - let _ = io::stdout().flush(); - }); - Self { - active, - handle: Some(handle), - } - } -} - -impl Drop for TuiProgress { - fn drop(&mut self) { - self.active.store(false, Ordering::Relaxed); - if let Some(handle) = self.handle.take() { - let _ = handle.join(); - } - } -} fn main() { - let args: Vec<_> = std::env::args_os().collect(); - trace_event( - "process_start", - json!({ - "pid": process::id(), - "args": args.iter().map(|arg| arg.to_string_lossy().to_string()).collect::>() - }), - ); - let cli = Cli::parse(); - let logger = Logger { - json: cli.json, - quiet: false, - }; - let preferences = ui_preferences_from_cli(&cli); - let sys = WinSys; - if cli.command.is_none() && sys.has_embedded_bundle() { - let exit_code = match run_bundled_installer(preferences, &sys, &logger) { - Ok(()) => 0, - Err(AppError::Message(ref message)) if message == "__elevated_relaunch__" => 0, - Err(err) => { - if preferences.headed - && !preferences.automation - && !preferences.json - && sys.ui_available() - && !failure_ux_shown() - && !matches!(err, AppError::Cancelled | AppError::CancelledByUser) - { - let _ = sys.ui_report_error(&err.to_string()); - } - let code = error_exit_code(&err); - logger.error(err, code); - code - } - }; - process::exit(exit_code); - } - - let exit_code = match run(cli, &sys, &logger) { - Ok(()) => 0, - Err(AppError::Message(message)) if message == "__elevated_relaunch__" => 0, - Err(err) => { - let code = error_exit_code(&err); - logger.error(err, code); - code - } - }; - process::exit(exit_code); -} - -fn error_exit_code(err: &AppError) -> i32 { - match err { - AppError::Cancelled | AppError::CancelledByUser => EXIT_CANCELLED, - AppError::Message(message) if message.contains("Elevation required") => { - EXIT_ELEVATION_REQUIRED - } - _ => EXIT_OPERATION_FAILED, - } -} - -fn run(cli: Cli, sys: &dyn Sys, logger: &Logger) -> Result<(), AppError> { - let preferences = ui_preferences_from_cli(&cli); - let command = cli.command.ok_or_else(|| { - AppError::Message( - "Missing command: expected package, install, uninstall, or cleanup".into(), - ) - })?; - match command { - Commands::Package { manifest, output } => package(&manifest, &output, logger), - Commands::Install { manifest, journal } => install( - &manifest, - journal, - cli.elevate, - select_ui(preferences, sys, logger)?, - preferences.automation, - sys, - None, - logger, - ), - Commands::Uninstall { journal } => uninstall( - &journal, - cli.elevate, - select_ui(preferences, sys, logger)?, - preferences.automation, - sys, - &mut None, - logger, - false, - ), - Commands::Cleanup { - target_exe, - install_root, - app_name, - } => cleanup( - target_exe, - install_root, - app_name, - select_ui(preferences, sys, logger)?, - preferences.automation, - sys, - logger, - ), - } -} - -fn package(manifest_path: &Path, output_root: &Path, logger: &Logger) -> Result<(), AppError> { - enforce_manifest_path_spacing(manifest_path)?; - let manifest = read_install_manifest(manifest_path)?; - let current_exe = std::env::current_exe()?; - let manifest_dir = manifest_path - .parent() - .ok_or_else(|| AppError::Message("Manifest must have a parent directory".into()))?; - fs::create_dir_all(output_root)?; - let installer_target = output_root.join("covenant-setup-installer.exe"); - build_packaged_installer( - &installer_target, - ¤t_exe, - manifest_dir, - manifest_path, - &manifest, - logger, - )?; - - logger.result( - "ok", - json!({ - "installer": installer_target - }), - ); - Ok(()) -} - -fn read_install_manifest(manifest_path: &Path) -> Result { - let manifest: InstallManifest = toml::from_str(&fs::read_to_string(manifest_path)?)?; - enforce_manifest_file_name(manifest_path, &manifest)?; - enforce_manifest_field_spacing(&manifest)?; - Ok(manifest) -} - -/// Packaging-time rule only: install must accept manifests from any -/// directory because bundled installs extract under %TEMP%, which contains -/// spaces for user profiles like `C:\Users\First Last`. -fn enforce_manifest_path_spacing(manifest_path: &Path) -> Result<(), AppError> { - let manifest_path_text = manifest_path.to_string_lossy(); - if contains_whitespace(&manifest_path_text) { - return Err(AppError::Message(format!( - "Manifest path cannot contain spaces: {}", - manifest_path.display() - ))); - } - - Ok(()) -} - -fn enforce_manifest_file_name( - manifest_path: &Path, - manifest: &InstallManifest, -) -> Result<(), AppError> { - let expected = expected_manifest_file_name(&manifest.app_name); - let actual = manifest_path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| AppError::Message("Manifest path must include a file name".into()))?; - if actual != expected { - return Err(AppError::Message(format!( - "Manifest file name must be '{expected}' for app_name '{}'; got '{actual}'", - manifest.app_name - ))); - } - - Ok(()) -} - -fn enforce_manifest_field_spacing(manifest: &InstallManifest) -> Result<(), AppError> { - for (index, directory) in manifest.directories.iter().enumerate() { - enforce_no_whitespace(&format!("directories.paths[{index}]"), directory)?; - } - - for (index, file) in manifest.files.iter().enumerate() { - enforce_no_whitespace(&format!("files[{index}].source"), &file.source)?; - enforce_no_whitespace(&format!("files[{index}].destination"), &file.destination)?; - } - - for (index, registry) in manifest.registry.iter().enumerate() { - enforce_no_whitespace(&format!("registry[{index}].key"), ®istry.key)?; - enforce_no_whitespace(&format!("registry[{index}].name"), ®istry.name)?; - enforce_no_whitespace(&format!("registry[{index}].value"), ®istry.value)?; - } - - for (index, shortcut) in manifest.shortcuts.iter().enumerate() { - enforce_no_whitespace(&format!("shortcuts[{index}].path"), &shortcut.path)?; - enforce_no_whitespace(&format!("shortcuts[{index}].target"), &shortcut.target)?; - enforce_optional_no_whitespace( - &format!("shortcuts[{index}].arguments"), - shortcut.arguments.as_deref(), - )?; - enforce_optional_no_whitespace( - &format!("shortcuts[{index}].working_directory"), - shortcut.working_directory.as_deref(), - )?; - } - - for (index, script) in manifest.scripts.iter().enumerate() { - enforce_no_whitespace(&format!("scripts[{index}].command"), &script.command)?; - for (arg_index, arg) in script.args.iter().enumerate() { - enforce_no_whitespace(&format!("scripts[{index}].args[{arg_index}]"), arg)?; - } - enforce_optional_no_whitespace( - &format!("scripts[{index}].working_directory"), - script.working_directory.as_deref(), - )?; - } - - for (index, branch) in manifest.purge.registry_branches.iter().enumerate() { - enforce_no_whitespace(&format!("purge.registry_branches[{index}]"), branch)?; - } - - for (index, path) in manifest.purge.paths.iter().enumerate() { - enforce_no_whitespace(&format!("purge.paths[{index}]"), path)?; - } - - Ok(()) -} - -fn enforce_optional_no_whitespace(field: &str, value: Option<&str>) -> Result<(), AppError> { - if let Some(value) = value { - enforce_no_whitespace(field, value)?; - } - Ok(()) -} - -fn enforce_no_whitespace(field: &str, value: &str) -> Result<(), AppError> { - if contains_whitespace(value) { - return Err(AppError::Message(format!( - "Manifest field '{field}' cannot contain spaces or other whitespace: {value}" - ))); - } - - Ok(()) -} - -fn contains_whitespace(value: &str) -> bool { - value.chars().any(char::is_whitespace) -} - -fn expected_manifest_file_name(app_name: &str) -> String { - let name: String = app_name - .trim() - .chars() - .filter(|ch| !ch.is_whitespace()) - .collect(); - if name.is_empty() { - "install.toml".to_string() - } else { - format!("{name}-install.toml") - } -} - -fn build_packaged_installer( - exe_target: &Path, - current_exe: &Path, - manifest_dir: &Path, - manifest_path: &Path, - manifest: &InstallManifest, - logger: &Logger, -) -> Result<(), AppError> { - fs::copy(current_exe, &exe_target)?; - let bundled_manifest_name = expected_manifest_file_name(&manifest.app_name); - let bundle = EmbeddedBundle { - metadata: PackagedApp { - app_name: manifest.app_name.clone(), - manifest: bundled_manifest_name.clone(), - }, - files: collect_bundle_files( - manifest_dir, - manifest_path, - &bundled_manifest_name, - &[exe_target.to_path_buf()], - )?, - }; - append_embedded_bundle(exe_target, &bundle)?; - logger.info( - "package_artifact", - json!({ - "exe": exe_target, - "embedded_files": bundle.files.len() - }), - ); - Ok(()) -} - -fn collect_bundle_files( - source_root: &Path, - manifest_path: &Path, - bundled_manifest_name: &str, - excluded_paths: &[PathBuf], -) -> Result, AppError> { - let mut files = Vec::new(); - collect_bundle_files_recursive( - source_root, - source_root, - manifest_path, - bundled_manifest_name, - excluded_paths, - &mut files, - )?; - Ok(files) -} - -fn collect_bundle_files_recursive( - source_root: &Path, - current: &Path, - manifest_path: &Path, - bundled_manifest_name: &str, - excluded_paths: &[PathBuf], - files: &mut Vec, -) -> Result<(), AppError> { - for entry in fs::read_dir(current)? { - let entry = entry?; - let path = entry.path(); - if excluded_paths - .iter() - .any(|excluded| same_path(&path, excluded)) - { - continue; - } - let relative = path - .strip_prefix(source_root) - .map_err(|_| AppError::Message("Failed to derive embedded file path".into()))?; - if should_exclude_from_bundle(relative) { - continue; - } - if path.is_dir() { - collect_bundle_files_recursive( - source_root, - &path, - manifest_path, - bundled_manifest_name, - excluded_paths, - files, - )?; - } else { - let relative_path = if same_path(&path, manifest_path) { - bundled_manifest_name.to_string() - } else { - relative.to_string_lossy().to_string() - }; - files.push(EmbeddedFile { - relative_path, - data: fs::read(&path)?, - }); - } - } - Ok(()) -} - -fn should_exclude_from_bundle(relative_path: &Path) -> bool { - let Some(file_name) = relative_path.file_name().and_then(|name| name.to_str()) else { - return false; - }; - matches!( - file_name.to_ascii_lowercase().as_str(), - "journal.json" | "covenant-setup-uninstall.exe" | "covenant-setup-installer.exe" - ) -} - -fn append_embedded_bundle(exe_target: &Path, bundle: &EmbeddedBundle) -> Result<(), AppError> { - let index = EmbeddedBundleIndex { - metadata: PackagedApp { - app_name: bundle.metadata.app_name.clone(), - manifest: bundle.metadata.manifest.clone(), - }, - files: bundle - .files - .iter() - .map(|file| EmbeddedFileIndexEntry { - relative_path: file.relative_path.clone(), - len: file.data.len() as u64, - }) - .collect(), - }; - let index_bytes = serde_json::to_vec(&index)?; - let mut payload = Vec::with_capacity( - std::mem::size_of::() - + index_bytes.len() - + bundle - .files - .iter() - .map(|file| file.data.len()) - .sum::(), - ); - payload.write_all(&(index_bytes.len() as u64).to_le_bytes())?; - payload.write_all(&index_bytes)?; - for file in &bundle.files { - payload.write_all(&file.data)?; - } - let mut file = fs::OpenOptions::new().append(true).open(exe_target)?; - file.write_all(&payload)?; - file.write_all(&(payload.len() as u64).to_le_bytes())?; - file.write_all(EMBEDDED_MAGIC)?; - Ok(()) -} - -fn read_embedded_bundle(exe_path: &Path) -> Result, AppError> { - let mut file = fs::File::open(exe_path)?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes)?; - let footer_len = EMBEDDED_MAGIC.len() + std::mem::size_of::(); - if bytes.len() < footer_len { - return Ok(None); - } - let magic_offset = bytes.len() - EMBEDDED_MAGIC.len(); - if &bytes[magic_offset..] != EMBEDDED_MAGIC { - return Ok(None); - } - let size_offset = magic_offset - std::mem::size_of::(); - let payload_len = u64::from_le_bytes( - bytes[size_offset..magic_offset] - .try_into() - .map_err(|_| AppError::Message("Invalid embedded payload footer".into()))?, - ) as usize; - if size_offset < payload_len { - return Err(AppError::Message( - "Embedded payload length exceeds executable size".into(), - )); - } - let payload_offset = size_offset - payload_len; - let payload = &bytes[payload_offset..size_offset]; - if payload.len() < std::mem::size_of::() { - return Err(AppError::Message("Embedded payload is too short".into())); - } - let index_len = u64::from_le_bytes( - payload[..std::mem::size_of::()] - .try_into() - .map_err(|_| AppError::Message("Invalid embedded index length".into()))?, - ) as usize; - let index_offset = std::mem::size_of::(); - let data_offset = index_offset - .checked_add(index_len) - .ok_or_else(|| AppError::Message("Embedded index length overflow".into()))?; - if data_offset > payload.len() { - return Err(AppError::Message( - "Embedded index length exceeds payload size".into(), - )); - } - let index: EmbeddedBundleIndex = serde_json::from_slice(&payload[index_offset..data_offset])?; - let EmbeddedBundleIndex { - metadata, - files: index_files, - } = index; - let mut cursor = data_offset; - let mut files = Vec::with_capacity(index_files.len()); - for entry in index_files { - let len = entry.len as usize; - let end = cursor - .checked_add(len) - .ok_or_else(|| AppError::Message("Embedded file length overflow".into()))?; - if end > payload.len() { - return Err(AppError::Message(format!( - "Embedded file exceeds payload size: {}", - entry.relative_path - ))); - } - files.push(EmbeddedFile { - relative_path: entry.relative_path, - data: payload[cursor..end].to_vec(), - }); - cursor = end; - } - if cursor != payload.len() { - return Err(AppError::Message( - "Embedded payload has trailing bytes after file data".into(), - )); - } - let bundle = EmbeddedBundle { metadata, files }; - Ok(Some(bundle)) -} - -fn extract_embedded_bundle(exe_path: &Path, bundle: &EmbeddedBundle) -> Result { - let temp_root = std::env::temp_dir().join("covenant-setup").join(format!( - "{}-{}", - exe_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .replace(' ', "_"), - process::id() - )); - if temp_root.exists() { - fs::remove_dir_all(&temp_root)?; - } - fs::create_dir_all(&temp_root)?; - for file in &bundle.files { - let target = temp_root.join(&file.relative_path); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent)?; - } - fs::write(target, &file.data)?; - } - Ok(temp_root) -} - -pub(crate) fn has_embedded_bundle() -> bool { - std::env::current_exe() - .ok() - .and_then(|exe| read_embedded_bundle(&exe).ok().flatten()) - .is_some() -} - -fn run_bundled_installer( - preferences: UiPreferences, - sys: &dyn Sys, - logger: &Logger, -) -> Result<(), AppError> { - trace_event("bundled_installer_start", json!({})); - let exe = std::env::current_exe()?; - let bundle = read_embedded_bundle(&exe)? - .ok_or_else(|| AppError::Message("No embedded package found in installer".into()))?; - let extraction_root = extract_embedded_bundle(&exe, &bundle)?; - trace_event( - "bundled_installer_extracted", - json!({"exe": exe, "extraction_root": extraction_root}), - ); - let metadata = bundle.metadata; - let manifest_path = extraction_root.join(metadata.manifest.clone()); - let ui_mode = select_ui(preferences, sys, logger)?; - trace_event( - "bundled_installer_ui_selected", - json!({"ui_mode": ui_mode_name(ui_mode), "automation": preferences.automation}), - ); - match install( - &manifest_path, - None, - true, - ui_mode, - preferences.automation, - sys, - None, - logger, - ) { - Ok(()) => { - trace_event("bundled_installer_install_ok", json!({})); - // The progress window now lingers showing the success result with - // a Close button, so no extra success prompt is needed. - Ok(()) - } - Err(err) => { - if matches!(err, AppError::CancelledByUser) { - trace_event("bundled_installer_install_cancelled", json!({})); - return Ok(()); - } - let err_msg = err.to_string(); - trace_event("bundled_installer_install_error", json!({"error": err_msg})); - if ui_mode == UiMode::Gui - && !preferences.automation - && !failure_ux_shown() - && !matches!(err, AppError::Cancelled) - { - sys.ui_report_error(&err_msg)?; - } - Err(err) - } - } -} - -fn write_journal(path: &Path, journal: &Journal) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, serde_json::to_vec_pretty(journal)?)?; - Ok(()) -} - -fn install( - manifest_path: &Path, - journal_path: Option, - elevate: bool, - ui_mode: UiMode, - automation: bool, - sys: &dyn Sys, - progress_override: Option>, - logger: &Logger, -) -> Result<(), AppError> { - let manifest = read_install_manifest(manifest_path)?; - trace_event( - "install_start", - json!({"manifest": manifest_path, "app_name": &manifest.app_name}), - ); - let app_name = manifest.app_name.clone(); - let _progress = start_tui_progress(ui_mode, format!("Installing {} ", manifest.app_name)); - let effective_logger = if ui_mode == UiMode::Tui { - logger.quiet_clone() - } else { - logger.clone() - }; - let resolver = win::PathResolver::new(&effective_logger)?; - let install_total = total_install_steps(&manifest); - let mut gui_progress = if progress_override.is_some() { - progress_override - } else { - let install_root = infer_install_root(&manifest, &resolver); - let branding_image = find_branding_image(manifest_path.parent(), install_root.as_deref()); - let show_welcome = !automation && !manifest.app_name.trim().is_empty(); - start_gui_progress( - ui_mode, - sys, - &format!("Installing {}", manifest.app_name), - Some(&manifest.app_name), - install_root.as_deref(), - branding_image.as_deref(), - show_welcome, - install_total, - &effective_logger, - )? - }; - let mut tracker = DeclaredTracker::new(); - let mut runtime_opt: Option = None; - let result = (|| -> Result<(), AppError> { - let requires_admin = manifest_requires_admin(&manifest, &resolver)?; - ensure_elevation_if_needed(requires_admin, elevate, sys, &effective_logger)?; - trace_event( - "install_elevation_checked", - json!({"requires_admin": requires_admin, "elevate": elevate}), - ); - let runtime = build_install_runtime( - &manifest, - manifest_path, - journal_path, - requires_admin, - &resolver, - )?; - runtime_opt = Some(runtime.clone()); - - let mut progress_step = 0usize; - for directory in &manifest.directories { - let path = resolver.resolve(directory); - effective_logger.info("create_directory", json!({"path":path})); - advance_gui_progress_step( - &mut gui_progress, - &mut progress_step, - install_total, - &format!("Creating directory {}", path.display()), - )?; - win::create_directory_recursive(&path, &effective_logger)?; - tracker.record(JournalAction::CreateDirectory { path }); - } - - for file in &manifest.files { - let source = absolutize(manifest_path.parent(), &file.source); - let destination = resolver.resolve(&file.destination); - if let Some(parent) = destination.parent() { - win::create_directory_recursive(parent, &effective_logger)?; - } - effective_logger.info( - "copy_file", - json!({"source":source,"destination":destination}), - ); - advance_gui_progress_step( - &mut gui_progress, - &mut progress_step, - install_total, - &format!("Copying file to {}", destination.display()), - )?; - win::copy_file(&source, &destination, &effective_logger)?; - tracker.record(JournalAction::CopyFile { - source, - destination, - }); - } - - for entry in &manifest.registry { - let (root, subkey) = parse_registry_key(&entry.key)?; - let resolved_value = resolver.resolve(&entry.value).to_string_lossy().to_string(); - effective_logger.info( - "write_registry", - json!({"key":entry.key,"name":entry.name,"value":resolved_value}), - ); - advance_gui_progress_step( - &mut gui_progress, - &mut progress_step, - install_total, - &format!("Writing registry value {} in {}", entry.name, entry.key), - )?; - sys.set_registry_string( - root, - &subkey, - &entry.name, - &resolved_value, - &effective_logger, - )?; - tracker.record(JournalAction::WriteRegistry { - root, - subkey, - name: entry.name.clone(), - }); - } - - for shortcut in &manifest.shortcuts { - let path = resolver.resolve(&shortcut.path); - let target = resolver.resolve(&shortcut.target); - let working_directory = shortcut - .working_directory - .as_deref() - .map(|v| resolver.resolve(v)); - if let Some(parent) = path.parent() { - win::create_directory_recursive(parent, &effective_logger)?; - } - effective_logger.info("create_shortcut", json!({"path":path,"target":target})); - advance_gui_progress_step( - &mut gui_progress, - &mut progress_step, - install_total, - &format!("Creating shortcut {}", path.display()), - )?; - win::create_shortcut( - &path, - &target, - shortcut.arguments.as_deref(), - working_directory.as_deref(), - shortcut.description.as_deref(), - &effective_logger, - )?; - tracker.record(JournalAction::CreateShortcut { path }); - } - - for script in &manifest.scripts { - let working_directory = script - .working_directory - .as_deref() - .map(|v| resolver.resolve(v)); - effective_logger.info("execute_script", json!({"command":script.command,"args":script.args,"working_directory":working_directory})); - advance_gui_progress_step( - &mut gui_progress, - &mut progress_step, - install_total, - &format!("Running script {}", script.command), - )?; - execute_script( - script, - manifest_path.parent(), - working_directory.as_deref(), - &mut gui_progress, - )?; - tracker.record(JournalAction::ExecuteScript { - command: script.command.clone(), - args: script.args.clone(), - working_directory, - }); - } - - if let Some(uninstall_exe_path) = &runtime.uninstall_exe_path { - advance_gui_progress_step( - &mut gui_progress, - &mut progress_step, - install_total, - &format!("Installing uninstaller {}", uninstall_exe_path.display()), - )?; - install_uninstaller(uninstall_exe_path, &effective_logger)?; - tracker.record(JournalAction::CopyFile { - source: std::env::current_exe()?, - destination: uninstall_exe_path.clone(), - }); - } - - if let (Some(install_root), Some(uninstall_exe_path)) = - (&runtime.install_root, &runtime.uninstall_exe_path) - { - advance_gui_progress_step( - &mut gui_progress, - &mut progress_step, - install_total, - &format!("Registering {} in Installed Apps", manifest.app_name), - )?; - register_uninstall_entry( - &manifest, - &runtime, - install_root, - uninstall_exe_path, - sys, - &effective_logger, - )?; - for value_name in [ - "DisplayName", - "Publisher", - "DisplayVersion", - "InstallLocation", - "DisplayIcon", - "UninstallString", - "QuietUninstallString", - ] { - tracker.record(JournalAction::WriteRegistry { - root: runtime.uninstall_registry_root, - subkey: runtime.uninstall_registry_key.clone(), - name: value_name.to_string(), - }); - } - } - - Ok(()) - })(); - - let journal = tracker.finish( - manifest.app_name.clone(), - Some(manifest_path.to_path_buf()), - manifest.purge.clone(), - manifest.support_contact.clone(), - ); - let result = result.and_then(|_| { - let runtime = runtime_opt - .as_ref() - .ok_or_else(|| AppError::Message("Install runtime was not initialized".to_string()))?; - write_journal(&runtime.journal_path, &journal)?; - trace_event( - "install_journal_written", - json!({"journal": runtime.journal_path, "actions": journal.actions.len()}), - ); - effective_logger.result( - "ok", - json!({"journal":runtime.journal_path,"actions":journal.actions.len()}), - ); - finish_gui_progress( - &mut gui_progress, - &format!("{} installation completed successfully", manifest.app_name), - !automation, - )?; - if ui_mode == UiMode::Tui { - println!("{} installation completed successfully", manifest.app_name); - } - Ok(()) - }); - - result.map_err(|err| { - trace_event( - "install_error", - json!({"app_name": app_name, "error": err.to_string()}), - ); - let cancelled = matches!(err, AppError::Cancelled); - let mut rollback_error_msg = None; - if let Some(runtime) = &runtime_opt { - if !journal.actions.is_empty() { - let write_res = (|| -> Result<(), AppError> { - write_journal(&runtime.journal_path, &journal)?; - let read_back = fs::read_to_string(&runtime.journal_path)?; - let _: Journal = serde_json::from_str(&read_back)?; - Ok(()) - })(); - match write_res { - Ok(()) => { - if cancelled && let Some(progress) = gui_progress.as_mut() { - // The flag already tripped the install loop; clear it - // so the rollback's own progress is not cancelled too. - progress.clear_cancel_request(); - let _ = progress.log("Cancel requested - reverting changes..."); - } - match uninstall( - &runtime.journal_path, - elevate, - ui_mode, - automation, - sys, - &mut gui_progress, - logger, - true, - ) { - Ok(()) => { - trace_event( - "rollback_success", - json!({"journal": runtime.journal_path, "actions": journal.actions.len()}), - ); - } - Err(u_err) => { - rollback_error_msg = Some(format!("Rollback failed: {u_err}")); - } - } - } - Err(w_err) => { - rollback_error_msg = - Some(format!("Could not write or verify journal.json: {w_err}")); - } - } - } - } - - if cancelled && rollback_error_msg.is_none() { - let _ = finish_gui_progress( - &mut gui_progress, - &format!("{app_name} installation cancelled. All changes were reverted."), - !automation, - ); - return AppError::Cancelled; - } - - let display_err = if let Some(r_err) = rollback_error_msg { - AppError::Message(format!( - "Installation failed: {err}. Additionally, rollback failed: {r_err}" - )) - } else { - err - }; - - let _ = fail_gui_progress( - &mut gui_progress, - &app_name, - "install", - &display_err, - manifest.support_contact.as_deref(), - !automation, - ); - display_err - }) -} - -fn uninstall( - journal_path: &Path, - elevate: bool, - ui_mode: UiMode, - automation: bool, - sys: &dyn Sys, - gui_progress: &mut Option>, - logger: &Logger, - is_rollback: bool, -) -> Result<(), AppError> { - let journal: Journal = serde_json::from_str(&fs::read_to_string(journal_path)?)?; - trace_event( - if is_rollback { - "rollback_start" - } else { - "uninstall_start" - }, - json!({"journal": journal_path, "app_name": &journal.app_name}), - ); - let app_name = journal.app_name.clone(); - let action_verb = if is_rollback { - "Reverting" - } else { - "Uninstalling" - }; - let _progress = start_tui_progress(ui_mode, format!("{} {} ", action_verb, journal.app_name)); - let uninstall_total = total_uninstall_steps(&journal); - // A rollback reuses the live install progress session passed in by the - // caller; only a standalone uninstall starts its own. - if gui_progress.is_none() { - *gui_progress = start_gui_progress( - ui_mode, - sys, - &format!("{} {}", action_verb, journal.app_name), - if is_rollback { - Some(&journal.app_name) - } else { - None - }, - None, - None, - false, - uninstall_total, - logger, - )?; - } - let result = (|| -> Result<(), AppError> { - let effective_logger = if ui_mode == UiMode::Tui { - logger.quiet_clone() - } else { - logger.clone() - }; - let resolver = win::PathResolver::new(&effective_logger)?; - let requires_admin = journal_requires_admin(&journal, &resolver)?; - ensure_elevation_if_needed(requires_admin, elevate, sys, &effective_logger)?; - trace_event( - "uninstall_elevation_checked", - json!({"requires_admin": requires_admin, "elevate": elevate}), - ); - let current_exe = std::env::current_exe().ok(); - let mut deferred_self_delete: Option = None; - let mut deferred_uninstall_registry: Vec<(RegistryRoot, String)> = Vec::new(); - let mut progress_step = 0usize; - - for action in journal.actions.iter().rev() { - match action { - JournalAction::CreateDirectory { path } => { - advance_gui_progress_step( - gui_progress, - &mut progress_step, - uninstall_total, - &format!("Removing directory {}", path.display()), - )?; - if journal_path.starts_with(path) && journal_path.exists() { - let _ = fs::remove_file(journal_path); - } - win::remove_directory_if_exists(path, &effective_logger)? - } - JournalAction::CopyFile { destination, .. } => { - if current_exe - .as_ref() - .is_some_and(|exe| same_path(exe, destination)) - { - effective_logger.info("defer_self_delete", json!({"path":destination})); - deferred_self_delete = Some(destination.clone()); - } else { - advance_gui_progress_step( - gui_progress, - &mut progress_step, - uninstall_total, - &format!("Removing file {}", destination.display()), - )?; - sys.remove_file_with_fallback(destination, &effective_logger)? - } - } - JournalAction::WriteRegistry { root, subkey, .. } => { - if is_uninstall_registry_key(subkey) { - push_unique_registry_branch( - &mut deferred_uninstall_registry, - *root, - subkey.clone(), - ); - } else { - advance_gui_progress_step( - gui_progress, - &mut progress_step, - uninstall_total, - &format!("Removing registry branch {}", subkey), - )?; - sys.delete_registry_tree(*root, subkey, &effective_logger)? - } - } - JournalAction::CreateShortcut { path } => { - advance_gui_progress_step( - gui_progress, - &mut progress_step, - uninstall_total, - &format!("Removing shortcut {}", path.display()), - )?; - sys.remove_file_with_fallback(path, &effective_logger)? - } - JournalAction::ExecuteScript { .. } => { - effective_logger.info("skip_script_rollback", json!({})) - } - } - } - - for branch in &journal.purge.registry_branches { - let (root, subkey) = parse_registry_key(branch)?; - advance_gui_progress_step( - gui_progress, - &mut progress_step, - uninstall_total, - &format!("Purging registry branch {}", branch), - )?; - sys.delete_registry_tree(root, &subkey, &effective_logger)?; - } - for path in &journal.purge.paths { - let resolved = resolver.resolve(path); - advance_gui_progress_step( - gui_progress, - &mut progress_step, - uninstall_total, - &format!("Purging path {}", resolved.display()), - )?; - purge_path(&resolved, sys, &effective_logger)?; - } - - for (root, subkey) in deferred_uninstall_registry { - advance_gui_progress_step( - gui_progress, - &mut progress_step, - uninstall_total, - &format!("Removing uninstall registration {}", subkey), - )?; - sys.delete_registry_tree(root, &subkey, &effective_logger)?; - } - - if let Some(path) = deferred_self_delete { - if !is_rollback { - // Never wait for the window here: the cleanup helper must be - // spawned promptly so self-deletion is not deferred on the user. - finish_gui_progress( - gui_progress, - &format!("Finalizing removal of {}", journal.app_name), - false, - )?; - } - sys.spawn_cleanup_helper( - &path, - path.parent(), - &journal.app_name, - ui_mode, - automation, - logger.json, - &effective_logger, - )?; - } else if !is_rollback { - // During a rollback the install error handler owns the single - // terminal message on the shared progress session. - finish_gui_progress( - gui_progress, - &format!("{} uninstalled successfully!", journal.app_name), - false, - )?; - if ui_mode == UiMode::Gui && !automation { - sys.ui_report_uninstall_success(&journal.app_name)?; - } - } - - effective_logger.result("ok", json!({"journal":journal_path})); - let _ = fs::remove_file(journal_path); - trace_event( - if is_rollback { - "rollback_ok" - } else { - "uninstall_ok" - }, - json!({"journal": journal_path}), - ); - Ok(()) - })(); - - if let Err(err) = &result { - trace_event( - if is_rollback { - "rollback_error" - } else { - "uninstall_error" - }, - json!({"app_name": app_name, "error": err.to_string()}), - ); - // Rollback errors bubble up so the install error handler can compose - // the single combined failure message on the shared session. - if !is_rollback { - let _ = fail_gui_progress( - gui_progress, - &app_name, - "uninstall", - err, - journal.support_contact.as_deref(), - !automation, - ); - } - } - - result -} - -fn cleanup( - target_exe: PathBuf, - install_root: Option, - app_name: String, - ui_mode: UiMode, - automation: bool, - sys: &dyn Sys, - logger: &Logger, -) -> Result<(), AppError> { - trace_event( - "cleanup_start", - json!({"target_exe": &target_exe, "install_root": &install_root, "app_name": &app_name}), - ); - let effective_logger = if ui_mode == UiMode::Tui { - logger.quiet_clone() - } else { - logger.clone() - }; - let mut reboot_required = false; - for _ in 0..50 { - if !target_exe.exists() { - break; - } - if fs::remove_file(&target_exe).is_ok() { - break; - } - thread::sleep(Duration::from_millis(200)); - } - if target_exe.exists() { - sys.remove_file_with_fallback(&target_exe, &effective_logger)?; - reboot_required = target_exe.exists(); - } - if let Some(install_root) = install_root { - if install_root.exists() && fs::read_dir(&install_root)?.next().is_none() { - win::remove_directory_if_exists(&install_root, &effective_logger)?; - } - } - reboot_required |= sys.schedule_helper_self_cleanup(&effective_logger)?; - if ui_mode == UiMode::Gui && !automation { - if reboot_required { - if sys.ui_prompt_uninstall_reboot(&app_name)? { - sys.spawn_reboot(&effective_logger)?; - } - } else { - sys.ui_report_uninstall_success(&app_name)?; - } - } else if ui_mode == UiMode::Tui { - if reboot_required { - println!( - "{app_name} uninstalled successfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now." - ); - if sys.prompt_reboot_tui()? { - sys.spawn_reboot(&effective_logger)?; - } - } else { - println!("{app_name} uninstalled successfully!"); - } - } - Ok(()) -} - -fn ensure_elevation_if_needed( - required: bool, - relaunch: bool, - sys: &dyn Sys, - logger: &Logger, -) -> Result<(), AppError> { - if !required || sys.is_elevated(logger)? { - trace_event( - "elevation_ok", - json!({"required": required, "relaunch": relaunch}), - ); - return Ok(()); - } - if relaunch { - trace_event("elevation_relaunch", json!({})); - sys.relaunch_as_admin(logger)?; - return Err(AppError::Message("__elevated_relaunch__".into())); - } - trace_event("elevation_required_error", json!({})); - Err(AppError::Message( - "Elevation required for requested operation".into(), - )) -} - -fn build_install_runtime( - manifest: &InstallManifest, - manifest_path: &Path, - journal_path: Option, - requires_admin: bool, - resolver: &win::PathResolver, -) -> Result { - let install_root = infer_install_root(manifest, resolver); - let journal_path = journal_path.unwrap_or_else(|| { - install_root - .clone() - .unwrap_or_else(|| { - manifest_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .to_path_buf() - }) - .join("journal.json") - }); - let uninstall_exe_path = install_root - .clone() - .map(|root| root.join("covenant-setup-uninstall.exe")); - let uninstall_registry_root = if requires_admin { - RegistryRoot::Hklm - } else { - RegistryRoot::Hkcu - }; - let uninstall_registry_key = format!( - "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}", - sanitize_registry_component(&manifest.app_name) - ); - Ok(InstallRuntime { - journal_path, - install_root, - uninstall_exe_path, - uninstall_registry_root, - uninstall_registry_key, - }) -} - -fn infer_install_root(manifest: &InstallManifest, resolver: &win::PathResolver) -> Option { - if let Some(path) = manifest.purge.paths.first() { - return Some(resolver.resolve(path)); - } - if let Some(directory) = manifest.directories.first() { - return Some(resolver.resolve(directory)); - } - if let Some(file) = manifest.files.first() { - return resolver - .resolve(&file.destination) - .parent() - .map(Path::to_path_buf); - } - None -} - -/// Branding ships with the payload, so the manifest directory is probed -/// first; the install root only exists on reinstalls over a previous tree. -fn find_branding_image(manifest_dir: Option<&Path>, install_root: Option<&Path>) -> Option { - const BRANDING_FILE_NAMES: [&str; 3] = ["branding.png", "banner.png", "logo.png"]; - manifest_dir - .into_iter() - .chain(install_root) - .flat_map(|root| BRANDING_FILE_NAMES.iter().map(move |name| root.join(name))) - .find(|path| path.is_file()) - .map(|path| path.to_string_lossy().into_owned()) -} - -fn install_uninstaller(uninstall_exe_path: &Path, logger: &Logger) -> Result<(), AppError> { - if let Some(parent) = uninstall_exe_path.parent() { - fs::create_dir_all(parent)?; - } - let current_exe = std::env::current_exe()?; - logger.info( - "install_uninstaller", - json!({"source":current_exe,"destination":uninstall_exe_path}), - ); - fs::copy(current_exe, uninstall_exe_path)?; - Ok(()) -} - -fn register_uninstall_entry( - manifest: &InstallManifest, - runtime: &InstallRuntime, - install_root: &Path, - uninstall_exe_path: &Path, - sys: &dyn Sys, - logger: &Logger, -) -> Result<(), AppError> { - let uninstall_command = format!( - "\"{}\" uninstall \"{}\" --elevate", - uninstall_exe_path.display(), - runtime.journal_path.display() - ); - let values = [ - ("DisplayName", manifest.app_name.clone()), - ("Publisher", "covenant-setup".to_string()), - ("DisplayVersion", env!("CARGO_PKG_VERSION").to_string()), - ( - "InstallLocation", - install_root.to_string_lossy().to_string(), - ), - ( - "DisplayIcon", - uninstall_exe_path.to_string_lossy().to_string(), - ), - ("UninstallString", uninstall_command.clone()), - ("QuietUninstallString", uninstall_command), - ]; - for (name, value) in values { - logger.info( - "register_uninstall_value", - json!({"key":runtime.uninstall_registry_key,"name":name,"value":value}), - ); - sys.set_registry_string( - runtime.uninstall_registry_root, - &runtime.uninstall_registry_key, - name, - &value, - logger, - )?; - } - Ok(()) -} - -pub(crate) fn spawn_cleanup_helper( - target_exe: &Path, - install_root: Option<&Path>, - app_name: &str, - ui_mode: UiMode, - automation: bool, - json: bool, - logger: &Logger, -) -> Result<(), AppError> { - let current_exe = std::env::current_exe()?; - let helper_path = std::env::temp_dir().join(format!( - "covenant-setup-cleanup-{}-{}.exe", - process::id(), - unique_ticks() - )); - logger.info( - "spawn_cleanup_helper", - json!({"helper":helper_path,"target_exe":target_exe,"install_root":install_root}), - ); - fs::copy(¤t_exe, &helper_path)?; - - let mut command = Command::new(&helper_path); - command.creation_flags(CREATE_NO_WINDOW); - if json { - command.arg("--json"); - } - if ui_mode == UiMode::Tui { - command.arg("--headless"); - } else if ui_mode == UiMode::Gui { - command.arg("--headed"); - } - if automation { - command.arg("--automation"); - } - command.arg("cleanup"); - command.arg("--target-exe"); - command.arg(target_exe); - if let Some(install_root) = install_root { - command.arg("--install-root"); - command.arg(install_root); - } - command.arg("--app-name"); - command.arg(app_name); - command.spawn()?; - Ok(()) -} - -fn start_tui_progress(ui_mode: UiMode, label: String) -> Option { - if ui_mode == UiMode::Tui { - Some(TuiProgress::start(label)) - } else { - None - } -} - -fn ui_preferences_from_cli(cli: &Cli) -> UiPreferences { - UiPreferences { - json: cli.json, - headless: cli.headless, - headed: cli.headed, - automation: cli.automation, - } -} - -fn select_ui( - preferences: UiPreferences, - sys: &dyn Sys, - logger: &Logger, -) -> Result { - if preferences.json { - return Ok(UiMode::None); - } - if preferences.headless && preferences.headed { - return Err(AppError::Message( - "Pass either --headed or --headless, not both".into(), - )); - } - if preferences.headless { - return Ok(UiMode::Tui); - } - if preferences.headed { - if !sys.ui_available() { - logger.info("gui_unavailable_fallback", json!({"fallback": "headless"})); - return Ok(UiMode::Tui); - } - return Ok(UiMode::Gui); - } - Err(AppError::Message( - "UI mode is required. Pass --headed for the WinForms UI, --headless for terminal progress, or --json for machine-readable output".into(), - )) -} - -fn ui_mode_name(ui_mode: UiMode) -> &'static str { - match ui_mode { - UiMode::None => "none", - UiMode::Gui => "gui", - UiMode::Tui => "tui", - } -} - -fn start_gui_progress( - ui_mode: UiMode, - sys: &dyn Sys, - title: &str, - app_name: Option<&str>, - install_root: Option<&Path>, - branding_image: Option<&str>, - show_welcome: bool, - total_steps: usize, - logger: &Logger, -) -> Result>, AppError> { - trace_event( - "gui_progress_start", - json!({ - "ui_mode": ui_mode_name(ui_mode), - "title": title, - "total_steps": total_steps.max(1), - "show_welcome": show_welcome - }), - ); - if let Some(sink) = sys.start_progress(ui_mode, title, total_steps.max(1))? { - return Ok(Some(sink)); - } - if ui_mode == UiMode::Gui { - Ok(Some(Box::new(ui::GuiProgress::start( - title, - app_name, - install_root - .map(|p| p.to_string_lossy().into_owned()) - .as_deref(), - branding_image, - show_welcome, - total_steps.max(1), - logger, - )?))) - } else { - Ok(None) - } -} - -fn advance_gui_progress( - gui_progress: &mut Option>, - current_step: usize, - total_steps: usize, - message: &str, -) -> Result<(), AppError> { - if gui_progress - .as_mut() - .is_some_and(|progress| progress.cancel_requested()) - { - trace_event( - "cancel_request_detected", - json!({"current_step": current_step}), - ); - return Err(AppError::Cancelled); - } - trace_event( - "progress", - json!({"current_step": current_step, "message": message}), - ); - if let Some(progress) = gui_progress.as_mut() { - progress.advance(current_step, total_steps, message)?; - } - Ok(()) -} - -fn advance_gui_progress_step( - gui_progress: &mut Option>, - current_step: &mut usize, - total_steps: usize, - message: &str, -) -> Result<(), AppError> { - *current_step += 1; - advance_gui_progress(gui_progress, *current_step, total_steps, message) -} - -fn finish_gui_progress( - gui_progress: &mut Option>, - message: &str, - wait_for_close: bool, -) -> Result<(), AppError> { - trace_event("progress_finish", json!({"message": message})); - if let Some(progress) = gui_progress.as_mut() { - progress.finish(message, wait_for_close)?; - } - Ok(()) -} - -fn fail_gui_progress( - gui_progress: &mut Option>, - app_name: &str, - operation: &str, - err: &AppError, - support_contact: Option<&str>, - wait_for_close: bool, -) -> Result<(), AppError> { - let message = format!("Error: program {app_name} failed to {operation} completely!"); - trace_event( - "progress_fail", - json!({"app_name": app_name, "operation": operation, "message": message, "error": err.to_string()}), - ); - if let Some(progress) = gui_progress.as_mut() { - progress.fail( - app_name, - operation, - &message, - &err.to_string(), - error_errata(app_name, operation, err, support_contact), - wait_for_close, - )?; - mark_failure_ux_shown(); - } - Ok(()) -} - -fn mark_failure_ux_shown() { - FAILURE_UX_SHOWN.store(true, Ordering::Relaxed); -} - -fn failure_ux_shown() -> bool { - FAILURE_UX_SHOWN.load(Ordering::Relaxed) -} - -fn error_errata( - app_name: &str, - operation: &str, - err: &AppError, - support_contact: Option<&str>, -) -> serde_json::Value { - let timestamp_unix_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map(|duration| duration.as_millis()); - json!({ - "schema": "covenant_setup_errata_v1", - "app_name": app_name, - "operation": operation, - "timestamp_unix_ms": timestamp_unix_ms, - "support_contact": support_contact, - "error": { - "message": err.to_string(), - "debug": format!("{err:?}"), - }, - "process": { - "pid": process::id(), - "exe": std::env::current_exe().ok().map(|path| path.display().to_string()), - "current_dir": std::env::current_dir().ok().map(|path| path.display().to_string()), - "args": std::env::args_os() - .map(|arg| arg.to_string_lossy().to_string()) - .collect::>(), - "trace_dir": std::env::var_os("COVENANT_SETUP_TRACE_DIR") - .map(|path| path.to_string_lossy().to_string()), - } - }) -} - -fn append_gui_shell_output( - gui_progress: &mut Option>, - bytes: &[u8], -) -> Result<(), AppError> { - if bytes.is_empty() { - return Ok(()); - } - if let Some(progress) = gui_progress.as_mut() { - let text = String::from_utf8_lossy(bytes); - for line in text.lines().filter(|line| !line.trim().is_empty()) { - trace_event("script_output", json!({"line": line})); - progress.log(line)?; - } - } - Ok(()) -} - -fn total_install_steps(manifest: &InstallManifest) -> usize { - manifest.directories.len() - + manifest.files.len() - + manifest.registry.len() - + manifest.shortcuts.len() - + manifest.scripts.len() - + 2 -} - -fn total_uninstall_steps(journal: &Journal) -> usize { - journal.actions.len() + journal.purge.registry_branches.len() + journal.purge.paths.len() + 2 -} - -pub(crate) fn schedule_helper_self_cleanup(logger: &Logger) -> Result { - let self_exe = std::env::current_exe()?; - logger.info("schedule_helper_self_cleanup", json!({"path":self_exe})); - let delete_command = format!( - "Start-Sleep -Seconds 2; Remove-Item -LiteralPath '{}' -Force -ErrorAction SilentlyContinue", - powershell_single_quote(&self_exe.to_string_lossy()) - ); - let mut command = Command::new("powershell.exe"); - command.creation_flags(CREATE_NO_WINDOW); - command.arg("-NoProfile"); - command.arg("-WindowStyle"); - command.arg("Hidden"); - command.arg("-Command"); - command.arg(OsString::from(delete_command)); - if command.spawn().is_ok() { - return Ok(false); - } - win::remove_file_with_fallback(&self_exe, logger)?; - Ok(true) -} - -pub(crate) fn trace_event(phase: &str, detail: impl Serialize) { - let Ok(root) = std::env::var("COVENANT_SETUP_TRACE_DIR") else { - return; - }; - if root.trim().is_empty() { - return; - } - - let root = PathBuf::from(root); - if fs::create_dir_all(&root).is_err() { - return; - } - let path = root.join(format!("installer-heartbeat-{}.jsonl", process::id())); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default(); - let event = json!({ - "time_unix_ms": timestamp, - "pid": process::id(), - "phase": phase, - "detail": detail - }); - if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path) { - let _ = writeln!(file, "{event}"); - } -} - -fn unique_ticks() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default() -} - -fn powershell_single_quote(value: &str) -> String { - value.replace('\'', "''") -} - -pub(crate) fn spawn_reboot(logger: &Logger) -> Result<(), AppError> { - logger.info("spawn_reboot", json!({})); - let mut command = Command::new("shutdown.exe"); - command.creation_flags(CREATE_NO_WINDOW); - command.args(["/r", "/t", "0"]); - command.spawn()?; - Ok(()) -} - -pub(crate) fn prompt_reboot_tui() -> Result { - print!("Restart now? [y/N]: "); - io::stdout().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - Ok(matches!( - input.trim().to_ascii_lowercase().as_str(), - "y" | "yes" - )) -} - -fn sanitize_registry_component(value: &str) -> String { - let sanitized: String = value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '_' - } - }) - .collect(); - if sanitized.is_empty() { - "covenant_setup".to_string() - } else { - sanitized - } -} - -fn manifest_requires_admin( - manifest: &InstallManifest, - resolver: &win::PathResolver, -) -> Result { - for directory in &manifest.directories { - if resolver.requires_admin(&resolver.resolve(directory)) { - return Ok(true); - } - } - for file in &manifest.files { - if resolver.requires_admin(&resolver.resolve(&file.destination)) { - return Ok(true); - } - } - for shortcut in &manifest.shortcuts { - if resolver.requires_admin(&resolver.resolve(&shortcut.path)) { - return Ok(true); - } - } - for key in &manifest.registry { - let (root, _) = parse_registry_key(&key.key)?; - if matches!(root, RegistryRoot::Hklm) { - return Ok(true); - } - } - Ok(false) -} - -fn journal_requires_admin( - journal: &Journal, - resolver: &win::PathResolver, -) -> Result { - for action in &journal.actions { - match action { - JournalAction::CreateDirectory { path } - | JournalAction::CopyFile { - destination: path, .. - } - | JournalAction::CreateShortcut { path } => { - if resolver.requires_admin(path) { - return Ok(true); - } - } - JournalAction::WriteRegistry { root, .. } if matches!(root, RegistryRoot::Hklm) => { - return Ok(true); - } - _ => {} - } - } - for branch in &journal.purge.registry_branches { - let (root, _) = parse_registry_key(branch)?; - if matches!(root, RegistryRoot::Hklm) { - return Ok(true); - } - } - for path in &journal.purge.paths { - if resolver.requires_admin(&resolver.resolve(path)) { - return Ok(true); - } - } - Ok(false) -} - -fn execute_script( - script: &ScriptSpec, - manifest_dir: Option<&Path>, - working_directory: Option<&Path>, - gui_progress: &mut Option>, -) -> Result<(), AppError> { - trace_event( - "script_start", - json!({"command": &script.command, "args": &script.args, "working_directory": working_directory}), - ); - let command_path = absolutize(manifest_dir, &script.command); - let command = if command_path.exists() { - command_path - } else { - PathBuf::from(&script.command) - }; - let mut process = Command::new(command); - process.creation_flags(CREATE_NO_WINDOW); - process.args(&script.args); - if let Some(dir) = working_directory { - process.current_dir(dir); - } - let output = process.output()?; - append_gui_shell_output(gui_progress, &output.stdout)?; - append_gui_shell_output(gui_progress, &output.stderr)?; - let status = output.status; - trace_event( - "script_exit", - json!({"command": &script.command, "status": status.code()}), - ); - if !status.success() { - return Err(AppError::Message(format!( - "Script failed: {} ({status})", - script.command - ))); - } - Ok(()) -} - -fn purge_path(path: &Path, sys: &dyn Sys, logger: &Logger) -> Result<(), AppError> { - if !path.exists() { - return Ok(()); - } - if path.is_file() { - return sys.remove_file_with_fallback(path, logger); - } - for entry in fs::read_dir(path)? { - let entry = entry?; - let child = entry.path(); - if child.is_dir() { - purge_path(&child, sys, logger)?; - } else { - sys.remove_file_with_fallback(&child, logger)?; - } - } - win::remove_directory_if_exists(path, logger) -} - -fn parse_registry_key(input: &str) -> Result<(RegistryRoot, String), AppError> { - if let Some(rest) = input.strip_prefix("HKCU\\") { - return Ok((RegistryRoot::Hkcu, rest.to_string())); - } - if let Some(rest) = input.strip_prefix("HKLM\\") { - return Ok((RegistryRoot::Hklm, rest.to_string())); - } - Err(AppError::Message(format!( - "Unsupported registry root: {input}" - ))) -} - -fn is_uninstall_registry_key(subkey: &str) -> bool { - subkey.starts_with("Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\") -} - -fn push_unique_registry_branch( - branches: &mut Vec<(RegistryRoot, String)>, - root: RegistryRoot, - subkey: String, -) { - if !branches.iter().any(|(existing_root, existing_subkey)| { - *existing_root == root && *existing_subkey == subkey - }) { - branches.push((root, subkey)); - } -} - -fn same_path(left: &Path, right: &Path) -> bool { - if let (Ok(left), Ok(right)) = (fs::canonicalize(left), fs::canonicalize(right)) { - return normalize_path_for_compare(&left) == normalize_path_for_compare(&right); - } - normalize_path_for_compare(left) == normalize_path_for_compare(right) -} - -fn normalize_path_for_compare(path: &Path) -> String { - let mut value = path.to_string_lossy().replace('/', "\\"); - if let Some(rest) = value.strip_prefix("\\\\?\\UNC\\") { - value = format!("\\\\{rest}"); - } else if let Some(rest) = value.strip_prefix("\\\\?\\") { - value = rest.to_string(); - } - while value.ends_with('\\') && !is_windows_root(&value) { - value.pop(); - } - value.to_ascii_lowercase() -} - -fn is_windows_root(path: &str) -> bool { - let bytes = path.as_bytes(); - (bytes.len() == 3 && bytes[1] == b':' && bytes[2] == b'\\') - || (path.starts_with("\\\\") && path[2..].matches('\\').count() <= 1) -} - -fn absolutize(base: Option<&Path>, value: &str) -> PathBuf { - let candidate = PathBuf::from(value); - if candidate.is_absolute() { - candidate - } else { - base.unwrap_or_else(|| Path::new(".")).join(candidate) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - struct TestDir { - path: PathBuf, - } - - impl TestDir { - fn new(name: &str) -> Self { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "covenant-setup-test-{name}-{}-{unique}", - process::id() - )); - fs::create_dir_all(&path).unwrap(); - Self { path } - } - - fn path(&self) -> &Path { - &self.path - } - } - - impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } - - #[test] - fn embedded_bundle_round_trips_through_exe_footer() { - let temp = TestDir::new("bundle-round-trip"); - let exe = temp.path().join("installer.exe"); - fs::write(&exe, b"stub executable bytes").unwrap(); - let manifest_name = expected_manifest_file_name("Round Trip App"); - - let bundle = EmbeddedBundle { - metadata: PackagedApp { - app_name: "Round Trip App".to_string(), - manifest: manifest_name.clone(), - }, - files: vec![ - EmbeddedFile { - relative_path: manifest_name, - data: b"app_name = 'Round Trip App'".to_vec(), - }, - EmbeddedFile { - relative_path: "payload\\tool.exe".to_string(), - data: vec![0, 1, 2, 3, 255], - }, - ], - }; - - append_embedded_bundle(&exe, &bundle).unwrap(); - let decoded = read_embedded_bundle(&exe).unwrap().unwrap(); - - assert_eq!(decoded, bundle); - assert!( - fs::read(&exe) - .unwrap() - .starts_with(b"stub executable bytes") - ); - } - - #[test] - fn read_embedded_bundle_returns_none_for_wrong_magic_footer() { - let temp = TestDir::new("wrong-magic"); - let exe = temp.path().join("plain.exe"); - let mut bytes = vec![0; std::mem::size_of::()]; - bytes.extend_from_slice(b"COVENANT_SETUP_BUNDLE_BAD"); - fs::write(&exe, bytes).unwrap(); - - assert!(read_embedded_bundle(&exe).unwrap().is_none()); - } - - #[test] - fn read_embedded_bundle_rejects_payload_length_past_file_start() { - let temp = TestDir::new("bad-payload-len"); - let exe = temp.path().join("installer.exe"); - let mut bytes = b"stub".to_vec(); - bytes.extend_from_slice(&100u64.to_le_bytes()); - bytes.extend_from_slice(EMBEDDED_MAGIC); - fs::write(&exe, bytes).unwrap(); - - let err = read_embedded_bundle(&exe).unwrap_err().to_string(); - assert!(err.contains("Embedded payload length exceeds executable size")); - } - - #[test] - fn read_embedded_bundle_rejects_short_payload() { - let temp = TestDir::new("short-payload"); - let exe = temp.path().join("installer.exe"); - write_embedded_payload(&exe, &[1, 2, 3, 4]); - - let err = read_embedded_bundle(&exe).unwrap_err().to_string(); - assert!(err.contains("Embedded payload is too short")); - } - - #[test] - fn read_embedded_bundle_rejects_index_length_past_payload() { - let temp = TestDir::new("bad-index-len"); - let exe = temp.path().join("installer.exe"); - write_embedded_payload(&exe, &100u64.to_le_bytes()); - - let err = read_embedded_bundle(&exe).unwrap_err().to_string(); - assert!(err.contains("Embedded index length exceeds payload size")); - } - - #[test] - fn read_embedded_bundle_rejects_file_length_past_payload() { - let temp = TestDir::new("bad-file-len"); - let exe = temp.path().join("installer.exe"); - let index = EmbeddedBundleIndex { - metadata: PackagedApp { - app_name: "Bad File".to_string(), - manifest: expected_manifest_file_name("Bad File"), - }, - files: vec![EmbeddedFileIndexEntry { - relative_path: "payload.bin".to_string(), - len: 10, - }], - }; - let mut payload = Vec::new(); - let index_bytes = serde_json::to_vec(&index).unwrap(); - payload.extend_from_slice(&(index_bytes.len() as u64).to_le_bytes()); - payload.extend_from_slice(&index_bytes); - write_embedded_payload(&exe, &payload); - - let err = read_embedded_bundle(&exe).unwrap_err().to_string(); - assert!(err.contains("Embedded file exceeds payload size")); - } - - #[test] - fn read_embedded_bundle_rejects_trailing_payload_bytes() { - let temp = TestDir::new("trailing-payload"); - let exe = temp.path().join("installer.exe"); - let index = EmbeddedBundleIndex { - metadata: PackagedApp { - app_name: "Trailing".to_string(), - manifest: expected_manifest_file_name("Trailing"), - }, - files: vec![EmbeddedFileIndexEntry { - relative_path: "empty.bin".to_string(), - len: 0, - }], - }; - let mut payload = Vec::new(); - let index_bytes = serde_json::to_vec(&index).unwrap(); - payload.extend_from_slice(&(index_bytes.len() as u64).to_le_bytes()); - payload.extend_from_slice(&index_bytes); - payload.push(1); - write_embedded_payload(&exe, &payload); - - let err = read_embedded_bundle(&exe).unwrap_err().to_string(); - assert!(err.contains("Embedded payload has trailing bytes")); - } - - #[test] - fn extract_embedded_bundle_writes_nested_files_to_temp_root() { - let temp = TestDir::new("extract-bundle"); - let exe = temp.path().join("installer with spaces.exe"); - fs::write(&exe, b"stub").unwrap(); - let bundle = EmbeddedBundle { - metadata: PackagedApp { - app_name: "Extract App".to_string(), - manifest: expected_manifest_file_name("Extract App"), - }, - files: vec![EmbeddedFile { - relative_path: "nested\\payload.txt".to_string(), - data: b"payload".to_vec(), - }], - }; - - let root = extract_embedded_bundle(&exe, &bundle).unwrap(); - assert_eq!( - fs::read(root.join("nested\\payload.txt")).unwrap(), - b"payload" - ); - assert!( - root.file_name() - .unwrap() - .to_string_lossy() - .contains("installer_with_spaces") - ); - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn read_embedded_bundle_returns_none_without_bundle_footer() { - let temp = TestDir::new("no-bundle"); - let exe = temp.path().join("plain.exe"); - fs::write(&exe, b"plain executable bytes").unwrap(); - - assert!(read_embedded_bundle(&exe).unwrap().is_none()); - } - - #[test] - fn build_packaged_installer_copies_stub_and_embeds_source_bundle() { - let temp = TestDir::new("package-installer"); - let source_root = temp.path().join("source"); - let payload_dir = source_root.join("payload"); - let manifest_path = source_root.join(expected_manifest_file_name("Packaged App")); - let current_exe = temp.path().join("current.exe"); - let exe_target = source_root - .join("dist") - .join("covenant-setup-installer.exe"); - fs::create_dir_all(&payload_dir).unwrap(); - fs::create_dir_all(exe_target.parent().unwrap()).unwrap(); - fs::write(¤t_exe, b"stub exe").unwrap(); - fs::write(&manifest_path, b"app_name = 'Packaged App'").unwrap(); - fs::write(payload_dir.join("app.bin"), b"payload bytes").unwrap(); - - let manifest = InstallManifest { - app_name: "Packaged App".to_string(), - directories: Vec::new(), - files: Vec::new(), - registry: Vec::new(), - shortcuts: Vec::new(), - scripts: Vec::new(), - purge: PurgeSpec::default(), - support_contact: None, - }; - - build_packaged_installer( - &exe_target, - ¤t_exe, - &source_root, - &manifest_path, - &manifest, - &quiet_logger(), - ) - .unwrap(); - - let exe_bytes = fs::read(&exe_target).unwrap(); - assert!(exe_bytes.starts_with(b"stub exe")); - let mut bundle = read_embedded_bundle(&exe_target).unwrap().unwrap(); - bundle - .files - .sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); - - assert_eq!(bundle.metadata.app_name, "Packaged App"); - assert_eq!( - bundle.metadata.manifest, - expected_manifest_file_name("Packaged App") - ); - assert_eq!(bundle.files.len(), 2); - assert_eq!( - bundle.files[0].relative_path, - expected_manifest_file_name("Packaged App") - ); - assert_eq!(bundle.files[1].relative_path, "payload\\app.bin"); - assert_eq!(bundle.files[1].data, b"payload bytes"); - } - - #[test] - fn collect_bundle_files_renames_manifest_and_preserves_nested_payloads() { - let temp = TestDir::new("collect-bundle"); - let manifest = temp.path().join("CollectedApp-install.toml"); - let bundled_manifest_name = expected_manifest_file_name("Collected App"); - let nested_dir = temp.path().join("payload").join("bin"); - let nested_file = nested_dir.join("app.cmd"); - let journal = temp.path().join("journal.json"); - let generated_installer = temp - .path() - .join("dist") - .join("covenant-setup-installer.exe"); - let generated_uninstaller = temp.path().join("covenant-setup-uninstall.exe"); - fs::create_dir_all(&nested_dir).unwrap(); - fs::create_dir_all(generated_installer.parent().unwrap()).unwrap(); - fs::write(&manifest, b"app_name = 'Collected App'").unwrap(); - fs::write(&nested_file, b"@echo off").unwrap(); - fs::write(&journal, b"{}").unwrap(); - fs::write(&generated_installer, b"generated installer").unwrap(); - fs::write(&generated_uninstaller, b"generated uninstaller").unwrap(); - - let mut files = collect_bundle_files( - temp.path(), - &manifest, - &bundled_manifest_name, - &[generated_installer.clone()], - ) - .unwrap(); - files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); - - assert_eq!(files.len(), 2); - assert_eq!(files[0].relative_path, bundled_manifest_name); - assert_eq!(files[0].data, b"app_name = 'Collected App'"); - assert_eq!(files[1].relative_path, "payload\\bin\\app.cmd"); - assert_eq!(files[1].data, b"@echo off"); - } - - #[test] - fn declared_tracker_records_actions_and_finishes_journal() { - let mut tracker = DeclaredTracker::new(); - tracker.record(JournalAction::CreateDirectory { - path: PathBuf::from("C:\\Apps\\Tracked"), - }); - - let journal = tracker.finish( - "Tracked App".to_string(), - Some(PathBuf::from("install.toml")), - PurgeSpec { - registry_branches: vec!["HKCU\\Software\\Tracked".to_string()], - paths: vec!["C:\\Apps\\Tracked".to_string()], - }, - None, - ); - - assert_eq!(journal.app_name, "Tracked App"); - assert_eq!(journal.actions.len(), 1); - assert_eq!(journal.purge.paths, vec!["C:\\Apps\\Tracked"]); - } - - #[test] - fn journal_serde_round_trips_all_action_variants() { - let journal = Journal { - app_name: "Serde App".to_string(), - manifest_path: Some(PathBuf::from("C:\\install\\app.toml")), - actions: vec![ - JournalAction::CreateDirectory { - path: PathBuf::from("C:\\Apps\\Serde"), - }, - JournalAction::CopyFile { - source: PathBuf::from("payload\\app.exe"), - destination: PathBuf::from("C:\\Apps\\Serde\\app.exe"), - }, - JournalAction::WriteRegistry { - root: RegistryRoot::Hkcu, - subkey: "Software\\SerdeApp".to_string(), - name: "InstallLocation".to_string(), - }, - JournalAction::CreateShortcut { - path: PathBuf::from("C:\\Users\\Public\\Desktop\\Serde.lnk"), - }, - JournalAction::ExecuteScript { - command: "powershell.exe".to_string(), - args: vec!["-NoProfile".to_string(), "-File".to_string()], - working_directory: Some(PathBuf::from("C:\\Apps\\Serde")), - }, - ], - purge: PurgeSpec { - registry_branches: vec!["HKCU\\Software\\Serde".to_string()], - paths: vec!["C:\\Apps\\Serde\\cache".to_string()], - }, - support_contact: None, - }; - - let serialized = serde_json::to_string_pretty(&journal).unwrap(); - let decoded: Journal = serde_json::from_str(&serialized).unwrap(); - - assert_eq!(decoded, journal); - assert!(serialized.contains("\"type\": \"create_directory\"")); - assert!(serialized.contains("\"root\": \"hkcu\"")); - } - - #[test] - fn manifest_deserializes_grouped_directories() { - let manifest: InstallManifest = toml::from_str( - r#" -app_name = 'Grouped App' - -[directories] -paths = [ - '{LocalAppData}\Grouped', - '{LocalAppData}\Grouped\bin', -] -"#, - ) - .unwrap(); - - assert_eq!(manifest.directories.len(), 2); - assert_eq!(manifest.directories[0], r"{LocalAppData}\Grouped"); - assert_eq!(manifest.directories[1], r"{LocalAppData}\Grouped\bin"); - } - - #[test] - fn manifest_rejects_legacy_directory_tables() { - let result = toml::from_str::( - r#" -app_name = 'Legacy App' - -[[directories]] -path = '{LocalAppData}\Legacy' - -[[directories]] -path = '{LocalAppData}\Legacy\bin' -"#, - ); - - assert!(result.is_err()); - } - - #[test] - fn parse_registry_key_accepts_supported_roots_and_rejects_unknown_roots() { - assert_eq!( - parse_registry_key("HKCU\\Software\\Example").unwrap(), - (RegistryRoot::Hkcu, "Software\\Example".to_string()) - ); - assert_eq!( - parse_registry_key("HKLM\\Software\\Example").unwrap(), - (RegistryRoot::Hklm, "Software\\Example".to_string()) - ); - assert!(parse_registry_key("HKCR\\Software\\Example").is_err()); - } - - #[test] - fn sanitize_registry_component_replaces_punctuation_and_defaults_empty_input() { - assert_eq!(sanitize_registry_component(""), "covenant_setup"); - assert_eq!( - sanitize_registry_component("Vendor App: 1.0/alpha"), - "Vendor_App__1_0_alpha" - ); - assert_eq!(sanitize_registry_component("AZaz09-_"), "AZaz09-_"); - } - - #[test] - fn normalize_path_for_compare_handles_case_slashes_and_verbatim_prefixes() { - assert_eq!( - normalize_path_for_compare(Path::new("C:/Apps/Example/")), - "c:\\apps\\example" - ); - assert_eq!( - normalize_path_for_compare(Path::new(r"\\?\C:\Apps\Example")), - "c:\\apps\\example" - ); - assert_eq!( - normalize_path_for_compare(Path::new(r"\\?\UNC\server\share\Example")), - r"\\server\share\example" - ); - } - - #[test] - fn path_root_and_absolutize_helpers_handle_expected_shapes() { - assert!(is_windows_root("C:\\")); - assert!(is_windows_root("\\\\server\\share")); - assert!(!is_windows_root("C:\\Apps")); - assert_eq!( - absolutize(Some(Path::new("C:\\Base")), "relative\\file.txt"), - PathBuf::from("C:\\Base\\relative\\file.txt") - ); - assert_eq!( - absolutize(Some(Path::new("C:\\Base")), "D:\\absolute\\file.txt"), - PathBuf::from("D:\\absolute\\file.txt") - ); - } - - #[test] - fn same_path_uses_normalized_fallback_for_missing_paths() { - assert!(same_path( - Path::new("C:/Missing/Example/"), - Path::new(r"\\?\C:\Missing\Example") - )); - assert!(!same_path( - Path::new("C:/Missing/Example"), - Path::new("C:/Missing/Other") - )); - } - - #[test] - fn same_path_uses_canonicalized_existing_paths() { - let temp = TestDir::new("same-path"); - let nested = temp.path().join("nested"); - let file = nested.join("payload.txt"); - fs::create_dir_all(&nested).unwrap(); - fs::write(&file, b"payload").unwrap(); - - assert!(same_path(&file, &nested.join(".").join("payload.txt"))); - } - - #[test] - fn manifest_file_name_must_match_app_name_convention() { - let manifest = InstallManifest { - app_name: "Sample App".to_string(), - directories: Vec::new(), - files: Vec::new(), - registry: Vec::new(), - shortcuts: Vec::new(), - scripts: Vec::new(), - purge: PurgeSpec::default(), - support_contact: None, - }; - - enforce_manifest_file_name(Path::new("SampleApp-install.toml"), &manifest).unwrap(); - - let err = enforce_manifest_file_name(Path::new("install.toml"), &manifest) - .unwrap_err() - .to_string(); - assert!(err.contains("SampleApp-install.toml")); - - // Only the file name is constrained at install time; the directory may - // contain spaces (bundled installs extract under %TEMP%). - enforce_manifest_file_name( - Path::new("manifest folder\\SampleApp-install.toml"), - &manifest, - ) - .unwrap(); - } - - #[test] - fn manifest_path_spacing_is_enforced_only_for_packaging() { - enforce_manifest_path_spacing(Path::new("C:\\work\\SampleApp-install.toml")).unwrap(); - - let err = - enforce_manifest_path_spacing(Path::new("manifest folder\\SampleApp-install.toml")) - .unwrap_err() - .to_string(); - assert!(err.contains("cannot contain spaces")); - } - - #[test] - fn read_install_manifest_accepts_spaces_in_parent_directory() { - let temp = TestDir::new("manifest-spaced-parent"); - let spaced_dir = temp.path().join("User Name"); - fs::create_dir_all(&spaced_dir).unwrap(); - let manifest_path = spaced_dir.join("SpacedDir-install.toml"); - fs::write(&manifest_path, "app_name = 'Spaced Dir'\n").unwrap(); - - read_install_manifest(&manifest_path).unwrap(); - } - - #[test] - fn find_branding_image_prefers_manifest_dir_over_install_root() { - let temp = TestDir::new("branding-image"); - let manifest_dir = temp.path().join("payload-src"); - let install_root = temp.path().join("install-root"); - fs::create_dir_all(&manifest_dir).unwrap(); - fs::create_dir_all(&install_root).unwrap(); - - assert_eq!( - find_branding_image(Some(&manifest_dir), Some(&install_root)), - None - ); - - fs::write(install_root.join("logo.png"), b"png").unwrap(); - assert_eq!( - find_branding_image(Some(&manifest_dir), Some(&install_root)), - Some(install_root.join("logo.png").to_string_lossy().into_owned()) - ); - - fs::write(manifest_dir.join("branding.png"), b"png").unwrap(); - assert_eq!( - find_branding_image(Some(&manifest_dir), Some(&install_root)), - Some( - manifest_dir - .join("branding.png") - .to_string_lossy() - .into_owned() - ) - ); - } - - #[test] - fn manifest_field_spacing_allows_only_app_name_and_description_spaces() { - let mut manifest = InstallManifest { - app_name: "Display Name With Spaces".to_string(), - directories: vec!["{LocalAppData}\\NoSpaces".to_string()], - files: vec![FileSpec { - source: "payload\\app.exe".to_string(), - destination: "{LocalAppData}\\NoSpaces\\app.exe".to_string(), - }], - registry: vec![RegistrySpec { - key: "HKCU\\Software\\NoSpaces".to_string(), - name: "InstallRoot".to_string(), - value: "{LocalAppData}\\NoSpaces".to_string(), - }], - shortcuts: vec![ShortcutSpec { - path: "{Desktop}\\NoSpaces.lnk".to_string(), - target: "{LocalAppData}\\NoSpaces\\app.exe".to_string(), - arguments: Some("--profile=default".to_string()), - working_directory: Some("{LocalAppData}\\NoSpaces".to_string()), - description: Some("Description can have spaces".to_string()), - }], - scripts: vec![ScriptSpec { - command: "powershell.exe".to_string(), - args: vec![ - "-ExecutionPolicy".to_string(), - "Bypass".to_string(), - "-File".to_string(), - "payload\\post_install.ps1".to_string(), - ], - working_directory: Some("{LocalAppData}\\NoSpaces".to_string()), - }], - purge: PurgeSpec { - registry_branches: vec!["HKCU\\Software\\NoSpaces".to_string()], - paths: vec!["{LocalAppData}\\NoSpaces".to_string()], - }, - support_contact: None, - }; - - enforce_manifest_field_spacing(&manifest).unwrap(); - - manifest.scripts[0].args[3] = "payload\\post install.ps1".to_string(); - let err = enforce_manifest_field_spacing(&manifest) - .unwrap_err() - .to_string(); - assert!(err.contains("scripts[0].args[3]")); - assert!(err.contains("cannot contain spaces")); - } - - #[test] - fn read_install_manifest_rejects_space_in_manifest_field() { - let temp = TestDir::new("manifest-field-spaces"); - let manifest_path = temp.path().join("SpaceAllowed-install.toml"); - fs::write( - &manifest_path, - r#" -app_name = 'Space Allowed' - -[directories] -paths = ['{LocalAppData}\Bad Path'] - -[[shortcuts]] -path = '{Desktop}\NoSpaces.lnk' -target = '{LocalAppData}\NoSpaces\app.exe' -description = 'Description can have spaces' -"#, - ) - .unwrap(); - - let err = read_install_manifest(&manifest_path) - .unwrap_err() - .to_string(); - - assert!(err.contains("directories.paths[0]")); - assert!(err.contains("cannot contain spaces")); - } - - #[test] - fn should_exclude_from_bundle_matches_generated_artifacts_only() { - assert!(should_exclude_from_bundle(Path::new("journal.json"))); - assert!(should_exclude_from_bundle(Path::new( - "dist\\covenant-setup-installer.exe" - ))); - assert!(should_exclude_from_bundle(Path::new( - "covenant-setup-uninstall.exe" - ))); - assert!(!should_exclude_from_bundle(Path::new("payload\\app.exe"))); - assert!(!should_exclude_from_bundle(Path::new(""))); - } - - #[test] - fn cli_parses_bundled_flags_without_manual_preparse() { - let bundled = Cli::try_parse_from(["setup.exe", "--headed", "--automation"]).unwrap(); - assert!(bundled.command.is_none()); - assert!(bundled.headed); - assert!(bundled.automation); - - let direct = - Cli::try_parse_from(["setup.exe", "--headless", "install", "manifest.toml"]).unwrap(); - assert!(matches!(direct.command, Some(Commands::Install { .. }))); - assert!(direct.headless); - } - - #[test] - fn run_without_command_reports_missing_command() { - let cli = Cli::try_parse_from(["setup.exe", "--headless"]).unwrap(); - let err = run(cli, &WinSys, &quiet_logger()).unwrap_err().to_string(); - - assert!(err.contains("Missing command")); - } - - #[test] - fn run_package_command_creates_packaged_installer() { - let temp = TestDir::new("run-package"); - let manifest = temp.path().join("RunPackage-install.toml"); - let output = temp.path().join("dist"); - fs::write(&manifest, "app_name = 'Run Package'\n").unwrap(); - - let cli = Cli::try_parse_from([ - OsString::from("setup.exe"), - OsString::from("--json"), - OsString::from("package"), - manifest.clone().into_os_string(), - OsString::from("--output"), - output.clone().into_os_string(), - ]) - .unwrap(); - run(cli, &WinSys, &quiet_logger()).unwrap(); - - let installer = output.join("covenant-setup-installer.exe"); - assert!(installer.is_file()); - assert_eq!( - read_embedded_bundle(&installer) - .unwrap() - .unwrap() - .metadata - .app_name, - "Run Package" - ); - } - - #[test] - fn ui_preferences_and_selection_cover_explicit_modes() { - let json_cli = Cli::try_parse_from(["setup.exe", "--json", "--headed"]).unwrap(); - let json_preferences = ui_preferences_from_cli(&json_cli); - assert!(json_preferences.json); - assert_eq!( - select_ui(json_preferences, &WinSys, &quiet_logger()).unwrap(), - UiMode::None - ); - - let headless_cli = Cli::try_parse_from(["setup.exe", "--headless"]).unwrap(); - assert_eq!( - select_ui( - ui_preferences_from_cli(&headless_cli), - &WinSys, - &quiet_logger() - ) - .unwrap(), - UiMode::Tui - ); - - let missing = UiPreferences { - json: false, - headless: false, - headed: false, - automation: false, - }; - assert!(select_ui(missing, &WinSys, &quiet_logger()).is_err()); - - let conflict = UiPreferences { - json: false, - headless: true, - headed: true, - automation: false, - }; - assert!(select_ui(conflict, &WinSys, &quiet_logger()).is_err()); - } - - #[test] - fn headed_selection_uses_gui_or_documented_fallback() { - let mode = select_ui( - UiPreferences { - json: false, - headless: false, - headed: true, - automation: false, - }, - &WinSys, - &quiet_logger(), - ) - .unwrap(); - - assert!(matches!(mode, UiMode::Gui | UiMode::Tui)); - } - - #[test] - fn ui_mode_names_are_stable() { - assert_eq!(ui_mode_name(UiMode::None), "none"); - assert_eq!(ui_mode_name(UiMode::Gui), "gui"); - assert_eq!(ui_mode_name(UiMode::Tui), "tui"); - } - - #[test] - fn logger_methods_and_quiet_clone_are_callable() { - for json_output in [false, true] { - let logger = Logger { - json: json_output, - quiet: false, - }; - logger.info("test_event", json!({"value": 1})); - logger.result("ok", json!({"value": 2})); - logger.error("boom", 7); - - let quiet = logger.quiet_clone(); - assert!(quiet.quiet); - assert_eq!(quiet.json, json_output); - quiet.info("hidden", json!({})); - quiet.result("hidden", json!({})); - } - } - - #[test] - fn tui_progress_can_start_and_stop() { - let progress = start_tui_progress(UiMode::Tui, "Testing ".to_string()); - assert!(progress.is_some()); - drop(progress); - assert!(start_tui_progress(UiMode::None, "Testing ".to_string()).is_none()); - } - - #[test] - fn progress_helpers_are_noops_without_gui_progress() { - let mut progress: Option> = None; - let mut step = 0; - - assert!( - start_gui_progress( - UiMode::None, - &WinSys, - "No UI", - None, - None, - None, - false, - 0, - &quiet_logger() - ) - .unwrap() - .is_none() - ); - advance_gui_progress(&mut progress, 1, 3, "step").unwrap(); - advance_gui_progress_step(&mut progress, &mut step, 3, "next").unwrap(); - finish_gui_progress(&mut progress, "done", false).unwrap(); - fail_gui_progress( - &mut progress, - "App", - "install", - &AppError::Message("boom".to_string()), - None, - false, - ) - .unwrap(); - append_gui_shell_output(&mut progress, b"").unwrap(); - append_gui_shell_output(&mut progress, b"line 1\n\nline 2\n").unwrap(); - - assert_eq!(step, 1); - } - - #[test] - fn failure_ux_marker_tracks_whether_failure_was_shown() { - FAILURE_UX_SHOWN.store(false, Ordering::Relaxed); - assert!(!failure_ux_shown()); - - mark_failure_ux_shown(); - assert!(failure_ux_shown()); - - FAILURE_UX_SHOWN.store(false, Ordering::Relaxed); - } - - #[test] - fn embedded_bundle_probe_is_false_for_test_binary() { - assert!(!has_embedded_bundle()); - } - - #[test] - fn elevation_not_required_short_circuits_without_admin_probe() { - ensure_elevation_if_needed(false, false, &WinSys, &quiet_logger()).unwrap(); - } - - #[test] - fn error_errata_contains_operation_error_and_process_context() { - let errata = error_errata( - "Errata App", - "install", - &AppError::Message("boom".into()), - Some("support@example.com"), - ); - - assert_eq!(errata["schema"], "covenant_setup_errata_v1"); - assert_eq!(errata["app_name"], "Errata App"); - assert_eq!(errata["operation"], "install"); - assert_eq!(errata["error"]["message"], "boom"); - assert_eq!(errata["support_contact"], "support@example.com"); - assert!(errata["process"]["pid"].as_u64().is_some()); - assert!(errata["process"]["args"].as_array().is_some()); - } - - #[test] - fn total_step_helpers_count_manifest_and_journal_work() { - let manifest = sample_manifest(); - assert_eq!(total_install_steps(&manifest), 7); - - let journal = Journal { - app_name: "Steps".to_string(), - manifest_path: None, - actions: vec![ - JournalAction::CreateDirectory { - path: PathBuf::from("C:\\Apps\\Steps"), - }, - JournalAction::CreateShortcut { - path: PathBuf::from("C:\\Users\\Public\\Desktop\\Steps.lnk"), - }, - ], - purge: PurgeSpec { - registry_branches: vec!["HKCU\\Software\\Steps".to_string()], - paths: vec!["C:\\Apps\\Steps\\Cache".to_string()], - }, - support_contact: None, - }; - assert_eq!(total_uninstall_steps(&journal), 6); - } - - #[test] - fn install_empty_manifest_writes_minimal_journal() { - let temp = TestDir::new("install-empty"); - let manifest_path = temp.path().join("EmptyApp-install.toml"); - fs::write(&manifest_path, "app_name = 'Empty App'\n").unwrap(); - - install( - &manifest_path, - None, - false, - UiMode::None, - true, - &WinSys, - None, - &quiet_logger(), - ) - .unwrap(); - - let journal_path = temp.path().join("journal.json"); - let journal: Journal = - serde_json::from_str(&fs::read_to_string(journal_path).unwrap()).unwrap(); - assert_eq!(journal.app_name, "Empty App"); - assert_eq!(journal.manifest_path, Some(manifest_path)); - assert!(journal.actions.is_empty()); - assert_eq!(journal.purge, PurgeSpec::default()); - } - - #[test] - fn uninstall_empty_journal_succeeds_without_actions() { - let temp = TestDir::new("uninstall-empty"); - let journal_path = temp.path().join("journal.json"); - let journal = Journal { - app_name: "Empty App".to_string(), - manifest_path: None, - actions: Vec::new(), - purge: PurgeSpec::default(), - support_contact: None, - }; - fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); - - uninstall( - &journal_path, - false, - UiMode::None, - true, - &WinSys, - &mut None, - &quiet_logger(), - false, - ) - .unwrap(); - } - - #[test] - fn execute_script_reports_success_and_failure_status() { - let mut progress = None; - let ok = ScriptSpec { - command: "cmd.exe".to_string(), - args: vec!["/C".to_string(), "exit 0".to_string()], - working_directory: None, - }; - execute_script(&ok, None, None, &mut progress).unwrap(); - - let failing = ScriptSpec { - command: "cmd.exe".to_string(), - args: vec!["/C".to_string(), "exit 7".to_string()], - working_directory: None, - }; - let err = execute_script(&failing, None, None, &mut progress) - .unwrap_err() - .to_string(); - assert!(err.contains("Script failed: cmd.exe")); - } - - #[test] - fn purge_path_removes_nested_temp_tree_and_noops_when_missing() { - let temp = TestDir::new("purge-path"); - let root = temp.path().join("root"); - let nested = root.join("nested"); - fs::create_dir_all(&nested).unwrap(); - fs::write(nested.join("payload.txt"), b"payload").unwrap(); - - purge_path(&root, &WinSys, &quiet_logger()).unwrap(); - purge_path(&root, &WinSys, &quiet_logger()).unwrap(); - - assert!(!root.exists()); - } - - #[test] - fn purge_path_removes_single_file() { - let temp = TestDir::new("purge-file"); - let file = temp.path().join("payload.txt"); - fs::write(&file, b"payload").unwrap(); - - purge_path(&file, &WinSys, &quiet_logger()).unwrap(); - - assert!(!file.exists()); - } - - #[test] - fn install_uninstaller_copies_current_exe_to_target() { - let temp = TestDir::new("install-uninstaller"); - let target = temp.path().join("bin").join("covenant-setup-uninstall.exe"); - - install_uninstaller(&target, &quiet_logger()).unwrap(); - - assert!(target.is_file()); - assert!(fs::metadata(target).unwrap().len() > 0); - } - - #[test] - fn execute_script_uses_manifest_relative_command_and_working_directory() { - let temp = TestDir::new("script-relative"); - let script = temp.path().join("ok.cmd"); - let working_directory = temp.path().join("wd"); - fs::create_dir_all(&working_directory).unwrap(); - fs::write(&script, "@echo off\r\ncd\r\nexit /B 0\r\n").unwrap(); - - let spec = ScriptSpec { - command: "ok.cmd".to_string(), - args: Vec::new(), - working_directory: None, - }; - let mut progress = None; - - execute_script( - &spec, - Some(temp.path()), - Some(&working_directory), - &mut progress, - ) - .unwrap(); - } - - #[test] - fn install_runtime_uses_inferred_or_explicit_journal_paths() { - let resolver = - win::PathResolver::with_roots_for_test(vec![PathBuf::from("C:\\Program Files")]); - let manifest = sample_manifest(); - let runtime = build_install_runtime( - &manifest, - Path::new("C:\\source\\install.toml"), - None, - true, - &resolver, - ) - .unwrap(); - - assert_eq!( - runtime.journal_path, - PathBuf::from("C:\\Apps\\Sample\\journal.json") - ); - assert_eq!( - runtime.uninstall_exe_path, - Some(PathBuf::from( - "C:\\Apps\\Sample\\covenant-setup-uninstall.exe" - )) - ); - assert_eq!(runtime.uninstall_registry_root, RegistryRoot::Hklm); - assert!(runtime.uninstall_registry_key.ends_with("Sample_App")); - - let explicit = build_install_runtime( - &manifest, - Path::new("C:\\source\\install.toml"), - Some(PathBuf::from("D:\\journal.json")), - false, - &resolver, - ) - .unwrap(); - assert_eq!(explicit.journal_path, PathBuf::from("D:\\journal.json")); - assert_eq!(explicit.uninstall_registry_root, RegistryRoot::Hkcu); - } - - #[test] - fn infer_install_root_prefers_purge_then_directory_then_file_parent() { - let resolver = win::PathResolver::with_roots_for_test(vec![]); - let mut manifest = sample_manifest(); - - assert_eq!( - infer_install_root(&manifest, &resolver), - Some(PathBuf::from("C:\\Apps\\Sample")) - ); - - manifest.purge.paths.clear(); - assert_eq!( - infer_install_root(&manifest, &resolver), - Some(PathBuf::from("C:\\Apps\\Sample\\bin")) - ); - - manifest.directories.clear(); - assert_eq!( - infer_install_root(&manifest, &resolver), - Some(PathBuf::from("C:\\Apps\\Sample")) - ); - - manifest.files.clear(); - assert_eq!(infer_install_root(&manifest, &resolver), None); - } - - #[test] - fn manifest_and_journal_admin_checks_use_resolved_paths_and_hklm() { - let resolver = - win::PathResolver::with_roots_for_test(vec![PathBuf::from("C:\\Program Files")]); - let mut manifest = sample_manifest(); - manifest.purge.paths = vec!["C:\\Users\\Alice\\App".to_string()]; - manifest.directories = vec!["C:\\Program Files\\Sample".to_string()]; - assert!(manifest_requires_admin(&manifest, &resolver).unwrap()); - - manifest.directories.clear(); - manifest.files.clear(); - manifest.shortcuts.clear(); - manifest.registry = vec![RegistrySpec { - key: "HKLM\\Software\\Sample".to_string(), - name: "Value".to_string(), - value: "Data".to_string(), - }]; - assert!(manifest_requires_admin(&manifest, &resolver).unwrap()); - - let journal = Journal { - app_name: "Admin".to_string(), - manifest_path: None, - actions: vec![JournalAction::CopyFile { - source: PathBuf::from("payload.exe"), - destination: PathBuf::from("C:\\Program Files\\Sample\\payload.exe"), - }], - purge: PurgeSpec::default(), - support_contact: None, - }; - assert!(journal_requires_admin(&journal, &resolver).unwrap()); - - let journal = Journal { - app_name: "Admin".to_string(), - manifest_path: None, - actions: vec![], - purge: PurgeSpec { - registry_branches: vec!["HKLM\\Software\\Sample".to_string()], - paths: vec![], - }, - support_contact: None, - }; - assert!(journal_requires_admin(&journal, &resolver).unwrap()); - } - - #[test] - fn manifest_and_journal_admin_checks_return_false_for_user_scope_work() { - let resolver = - win::PathResolver::with_roots_for_test(vec![PathBuf::from("C:\\Program Files")]); - let manifest = InstallManifest { - app_name: "User App".to_string(), - directories: vec!["C:\\Users\\Alice\\AppData\\Local\\UserApp".to_string()], - files: vec![FileSpec { - source: "app.exe".to_string(), - destination: "C:\\Users\\Alice\\AppData\\Local\\UserApp\\app.exe".to_string(), - }], - registry: vec![RegistrySpec { - key: "HKCU\\Software\\UserApp".to_string(), - name: "InstallLocation".to_string(), - value: "C:\\Users\\Alice\\AppData\\Local\\UserApp".to_string(), - }], - shortcuts: vec![ShortcutSpec { - path: "C:\\Users\\Alice\\Desktop\\UserApp.lnk".to_string(), - target: "C:\\Users\\Alice\\AppData\\Local\\UserApp\\app.exe".to_string(), - arguments: None, - working_directory: None, - description: None, - }], - scripts: Vec::new(), - purge: PurgeSpec { - registry_branches: vec!["HKCU\\Software\\UserApp".to_string()], - paths: vec!["C:\\Users\\Alice\\AppData\\Local\\UserApp".to_string()], - }, - support_contact: None, - }; - assert!(!manifest_requires_admin(&manifest, &resolver).unwrap()); - - let journal = Journal { - app_name: "User App".to_string(), - manifest_path: None, - actions: vec![ - JournalAction::CreateDirectory { - path: PathBuf::from("C:\\Users\\Alice\\AppData\\Local\\UserApp"), - }, - JournalAction::WriteRegistry { - root: RegistryRoot::Hkcu, - subkey: "Software\\UserApp".to_string(), - name: "InstallLocation".to_string(), - }, - ], - purge: manifest.purge, - support_contact: None, - }; - assert!(!journal_requires_admin(&journal, &resolver).unwrap()); - } - - #[test] - fn admin_checks_detect_file_shortcut_and_purge_paths() { - let resolver = - win::PathResolver::with_roots_for_test(vec![PathBuf::from("C:\\Program Files")]); - let mut manifest = sample_manifest(); - manifest.directories.clear(); - manifest.registry.clear(); - manifest.files = vec![FileSpec { - source: "payload.exe".to_string(), - destination: "C:\\Program Files\\Sample\\payload.exe".to_string(), - }]; - assert!(manifest_requires_admin(&manifest, &resolver).unwrap()); - - manifest.files.clear(); - manifest.shortcuts = vec![ShortcutSpec { - path: "C:\\Program Files\\Sample\\Sample.lnk".to_string(), - target: "C:\\Users\\Alice\\App\\app.exe".to_string(), - arguments: None, - working_directory: None, - description: None, - }]; - assert!(manifest_requires_admin(&manifest, &resolver).unwrap()); - - let journal = Journal { - app_name: "Admin".to_string(), - manifest_path: None, - actions: vec![JournalAction::CreateShortcut { - path: PathBuf::from("C:\\Program Files\\Sample\\Sample.lnk"), - }], - purge: PurgeSpec::default(), - support_contact: None, - }; - assert!(journal_requires_admin(&journal, &resolver).unwrap()); - - let journal = Journal { - app_name: "Admin".to_string(), - manifest_path: None, - actions: Vec::new(), - purge: PurgeSpec { - registry_branches: Vec::new(), - paths: vec!["C:\\Program Files\\Sample".to_string()], - }, - support_contact: None, - }; - assert!(journal_requires_admin(&journal, &resolver).unwrap()); - } - - #[test] - fn powershell_single_quote_doubles_embedded_quotes() { - assert_eq!( - powershell_single_quote("C:\\Alice's App"), - "C:\\Alice''s App" - ); - } - - #[test] - fn unique_ticks_returns_nonzero_timestamp() { - assert!(unique_ticks() > 0); - } - - #[test] - fn uninstall_registry_key_detection_matches_only_uninstall_branch() { - assert!(is_uninstall_registry_key( - "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Sample" - )); - assert!(!is_uninstall_registry_key("Software\\Sample")); - } - - #[test] - fn push_unique_registry_branch_deduplicates_root_and_subkey() { - let mut branches = Vec::new(); - push_unique_registry_branch( - &mut branches, - RegistryRoot::Hkcu, - "Software\\App".to_string(), - ); - push_unique_registry_branch( - &mut branches, - RegistryRoot::Hkcu, - "Software\\App".to_string(), - ); - push_unique_registry_branch( - &mut branches, - RegistryRoot::Hklm, - "Software\\App".to_string(), - ); - - assert_eq!(branches.len(), 2); - } - - // ------------------------------------------------------------------------- - // Mock infrastructure for the Sys trait + ProgressSink trait. These mocks - // record every boundary call the orchestration code makes so tests can - // assert on exact sequences without spawning Win32 / process / GUI side - // effects. - // ------------------------------------------------------------------------- - - use std::sync::Mutex; - - #[derive(Debug, Clone, PartialEq, Eq)] - enum SysCall { - IsElevated, - RelaunchAsAdmin, - SpawnReboot, - PromptRebootTui, - SpawnCleanupHelper { - target_exe: PathBuf, - install_root: Option, - app_name: String, - ui_mode: UiMode, - automation: bool, - json: bool, - }, - ScheduleHelperSelfCleanup, - SetRegistryString { - root: RegistryRoot, - subkey: String, - name: String, - value: String, - }, - DeleteRegistryTree { - root: RegistryRoot, - subkey: String, - }, - HasEmbeddedBundle, - UiAvailable, - UiReportError(String), - UiReportUninstallSuccess(String), - UiPromptUninstallReboot(String), - RemoveFileWithFallback(PathBuf), - StartProgress { - ui_mode: UiMode, - title: String, - total_steps: usize, - }, - } - - #[derive(Debug, Clone, PartialEq, Eq)] - enum SinkCall { - Advance { - current_step: usize, - total_steps: usize, - message: String, - }, - Log(String), - Finish { - message: String, - wait_for_close: bool, - }, - Fail { - app_name: String, - operation: String, - message: String, - error: String, - wait_for_close: bool, - }, - } - - #[derive(Default)] - struct MockProgressSink { - calls: Arc>>, - // When Some(n), cancel_requested() reports true once n Advance calls - // have been recorded; clear_cancel_request() resets it to None. - cancel_after_advances: Option, - } - - impl MockProgressSink { - fn new() -> Self { - Self::default() - } - - fn handle(&self) -> Arc>> { - self.calls.clone() - } - - fn recorded_advances(&self) -> usize { - self.calls - .lock() - .unwrap() - .iter() - .filter(|call| matches!(call, SinkCall::Advance { .. })) - .count() - } - } - - impl ProgressSink for MockProgressSink { - fn advance( - &mut self, - current_step: usize, - total_steps: usize, - message: &str, - ) -> Result<(), AppError> { - self.calls.lock().unwrap().push(SinkCall::Advance { - current_step, - total_steps, - message: message.to_string(), - }); - Ok(()) - } - - fn log(&mut self, message: &str) -> Result<(), AppError> { - self.calls - .lock() - .unwrap() - .push(SinkCall::Log(message.to_string())); - Ok(()) - } - - fn finish(&mut self, message: &str, wait_for_close: bool) -> Result<(), AppError> { - self.calls.lock().unwrap().push(SinkCall::Finish { - message: message.to_string(), - wait_for_close, - }); - Ok(()) - } - - fn cancel_requested(&mut self) -> bool { - self.cancel_after_advances - .is_some_and(|threshold| self.recorded_advances() >= threshold) - } - - fn clear_cancel_request(&mut self) { - self.cancel_after_advances = None; - } - - fn fail( - &mut self, - app_name: &str, - operation: &str, - message: &str, - error: &str, - _errata: serde_json::Value, - wait_for_close: bool, - ) -> Result<(), AppError> { - self.calls.lock().unwrap().push(SinkCall::Fail { - app_name: app_name.to_string(), - operation: operation.to_string(), - message: message.to_string(), - error: error.to_string(), - wait_for_close, - }); - Ok(()) - } - } - - #[derive(Default)] - struct MockSys { - calls: Mutex>, - is_elevated: Mutex, - ui_available: Mutex, - ui_prompt_uninstall_reboot: Mutex, - schedule_helper_self_cleanup: Mutex, - prompt_reboot_tui: Mutex, - has_embedded_bundle: Mutex, - progress_sink_calls: Mutex>>>>, - progress_sink_cancel_after: Mutex>, - } - - #[allow(dead_code)] - impl MockSys { - fn new() -> Self { - Self::default() - } - - fn recorded(&self) -> Vec { - self.calls.lock().unwrap().clone() - } - - fn set_is_elevated(&self, value: bool) { - *self.is_elevated.lock().unwrap() = value; - } - - fn set_ui_available(&self, value: bool) { - *self.ui_available.lock().unwrap() = value; - } - - fn set_ui_prompt_uninstall_reboot(&self, value: bool) { - *self.ui_prompt_uninstall_reboot.lock().unwrap() = value; - } - - fn set_schedule_helper_self_cleanup(&self, value: bool) { - *self.schedule_helper_self_cleanup.lock().unwrap() = value; - } - - fn set_prompt_reboot_tui(&self, value: bool) { - *self.prompt_reboot_tui.lock().unwrap() = value; - } - - fn install_progress_sink(&self) -> Arc>> { - let sink = MockProgressSink::new(); - let handle = sink.handle(); - *self.progress_sink_calls.lock().unwrap() = Some(handle.clone()); - // Box and stash a fresh sink each call to start_progress; use the - // shared handle so tests can read the recorded calls. - handle - } - - fn set_cancel_after_advances(&self, threshold: usize) { - *self.progress_sink_cancel_after.lock().unwrap() = Some(threshold); - } - } - - impl Sys for MockSys { - fn is_elevated(&self, _logger: &Logger) -> Result { - self.calls.lock().unwrap().push(SysCall::IsElevated); - Ok(*self.is_elevated.lock().unwrap()) - } - - fn relaunch_as_admin(&self, _logger: &Logger) -> Result<(), AppError> { - self.calls.lock().unwrap().push(SysCall::RelaunchAsAdmin); - Ok(()) - } - - fn spawn_reboot(&self, _logger: &Logger) -> Result<(), AppError> { - self.calls.lock().unwrap().push(SysCall::SpawnReboot); - Ok(()) - } - - fn prompt_reboot_tui(&self) -> Result { - self.calls.lock().unwrap().push(SysCall::PromptRebootTui); - Ok(*self.prompt_reboot_tui.lock().unwrap()) - } - - fn spawn_cleanup_helper( - &self, - target_exe: &Path, - install_root: Option<&Path>, - app_name: &str, - ui_mode: UiMode, - automation: bool, - json: bool, - _logger: &Logger, - ) -> Result<(), AppError> { - self.calls - .lock() - .unwrap() - .push(SysCall::SpawnCleanupHelper { - target_exe: target_exe.to_path_buf(), - install_root: install_root.map(Path::to_path_buf), - app_name: app_name.to_string(), - ui_mode, - automation, - json, - }); - Ok(()) - } - - fn schedule_helper_self_cleanup(&self, _logger: &Logger) -> Result { - self.calls - .lock() - .unwrap() - .push(SysCall::ScheduleHelperSelfCleanup); - Ok(*self.schedule_helper_self_cleanup.lock().unwrap()) - } - - fn set_registry_string( - &self, - root: RegistryRoot, - subkey: &str, - name: &str, - value: &str, - _logger: &Logger, - ) -> Result<(), AppError> { - self.calls.lock().unwrap().push(SysCall::SetRegistryString { - root, - subkey: subkey.to_string(), - name: name.to_string(), - value: value.to_string(), - }); - Ok(()) - } - - fn delete_registry_tree( - &self, - root: RegistryRoot, - subkey: &str, - _logger: &Logger, - ) -> Result<(), AppError> { - self.calls - .lock() - .unwrap() - .push(SysCall::DeleteRegistryTree { - root, - subkey: subkey.to_string(), - }); - Ok(()) - } - - fn has_embedded_bundle(&self) -> bool { - self.calls.lock().unwrap().push(SysCall::HasEmbeddedBundle); - *self.has_embedded_bundle.lock().unwrap() - } - - fn ui_available(&self) -> bool { - self.calls.lock().unwrap().push(SysCall::UiAvailable); - *self.ui_available.lock().unwrap() - } - - fn ui_report_error(&self, message: &str) -> Result<(), AppError> { - self.calls - .lock() - .unwrap() - .push(SysCall::UiReportError(message.to_string())); - Ok(()) - } - - fn ui_report_uninstall_success(&self, app_name: &str) -> Result<(), AppError> { - self.calls - .lock() - .unwrap() - .push(SysCall::UiReportUninstallSuccess(app_name.to_string())); - Ok(()) - } - - fn ui_prompt_uninstall_reboot(&self, app_name: &str) -> Result { - self.calls - .lock() - .unwrap() - .push(SysCall::UiPromptUninstallReboot(app_name.to_string())); - Ok(*self.ui_prompt_uninstall_reboot.lock().unwrap()) - } - - fn remove_file_with_fallback(&self, path: &Path, _logger: &Logger) -> Result<(), AppError> { - self.calls - .lock() - .unwrap() - .push(SysCall::RemoveFileWithFallback(path.to_path_buf())); - // Best-effort delete the real file so subsequent fs::read_dir checks - // in cleanup() see the directory as empty. - let _ = std::fs::remove_file(path); - Ok(()) - } - - fn start_progress( - &self, - ui_mode: UiMode, - title: &str, - total_steps: usize, - ) -> Result>, AppError> { - self.calls.lock().unwrap().push(SysCall::StartProgress { - ui_mode, - title: title.to_string(), - total_steps, - }); - // Only inject a recording sink when test code explicitly opted in. - if let Some(handle) = self.progress_sink_calls.lock().unwrap().clone() { - let sink = MockProgressSink { - calls: handle.clone(), - cancel_after_advances: *self.progress_sink_cancel_after.lock().unwrap(), - }; - Ok(Some(Box::new(sink) as Box)) - } else { - Ok(None) - } - } - } - - // (a) - #[test] - fn ensure_elevation_if_needed_relaunches_when_required_and_relaunch_flag_set() { - let sys = MockSys::new(); - sys.set_is_elevated(false); - let err = ensure_elevation_if_needed(true, true, &sys, &quiet_logger()).unwrap_err(); - assert_eq!(err.to_string(), "__elevated_relaunch__"); - let calls = sys.recorded(); - assert!(matches!(calls[0], SysCall::IsElevated)); - assert!(matches!(calls[1], SysCall::RelaunchAsAdmin)); - } - - // (b) - #[test] - fn ensure_elevation_if_needed_errors_when_required_and_no_relaunch() { - let sys = MockSys::new(); - sys.set_is_elevated(false); - let err = ensure_elevation_if_needed(true, false, &sys, &quiet_logger()).unwrap_err(); - let message = err.to_string(); - assert!(message.contains("Elevation required")); - let calls = sys.recorded(); - assert_eq!(calls.len(), 1); - assert!(matches!(calls[0], SysCall::IsElevated)); - } - - // (c) - #[test] - fn ensure_elevation_if_needed_passes_when_already_elevated() { - let sys = MockSys::new(); - sys.set_is_elevated(true); - ensure_elevation_if_needed(true, true, &sys, &quiet_logger()).unwrap(); - let calls = sys.recorded(); - assert_eq!(calls.len(), 1); - assert!(matches!(calls[0], SysCall::IsElevated)); - } - - // (d) - #[test] - fn cleanup_prompts_and_spawns_reboot_when_required_in_gui_mode() { - let temp = TestDir::new("cleanup-gui-reboot"); - let target_exe = temp.path().join("ghost.exe"); - // Don't create the file — cleanup() short-circuits on !exists(). - let sys = MockSys::new(); - sys.set_schedule_helper_self_cleanup(true); - sys.set_ui_prompt_uninstall_reboot(true); - cleanup( - target_exe, - None, - "Sample".to_string(), - UiMode::Gui, - false, - &sys, - &quiet_logger(), - ) - .unwrap(); - let calls = sys.recorded(); - assert!( - calls - .iter() - .any(|c| matches!(c, SysCall::ScheduleHelperSelfCleanup)) - ); - assert!( - calls - .iter() - .any(|c| matches!(c, SysCall::UiPromptUninstallReboot(name) if name == "Sample")) - ); - assert!(calls.iter().any(|c| matches!(c, SysCall::SpawnReboot))); - } - - // (e) - #[test] - fn cleanup_skips_reboot_when_user_declines() { - let temp = TestDir::new("cleanup-decline"); - let target_exe = temp.path().join("ghost.exe"); - let sys = MockSys::new(); - sys.set_schedule_helper_self_cleanup(true); - sys.set_ui_prompt_uninstall_reboot(false); - cleanup( - target_exe, - None, - "Sample".to_string(), - UiMode::Gui, - false, - &sys, - &quiet_logger(), - ) - .unwrap(); - let calls = sys.recorded(); - assert!( - calls - .iter() - .any(|c| matches!(c, SysCall::UiPromptUninstallReboot(_))) - ); - assert!(!calls.iter().any(|c| matches!(c, SysCall::SpawnReboot))); - } - - // (f) - #[test] - fn cleanup_tui_path_skips_prompt_when_no_reboot_needed() { - let temp = TestDir::new("cleanup-tui-no-reboot"); - let target_exe = temp.path().join("ghost.exe"); - let sys = MockSys::new(); - sys.set_schedule_helper_self_cleanup(false); - cleanup( - target_exe, - None, - "Sample".to_string(), - UiMode::Tui, - false, - &sys, - &quiet_logger(), - ) - .unwrap(); - let calls = sys.recorded(); - assert!( - !calls - .iter() - .any(|c| matches!(c, SysCall::UiPromptUninstallReboot(_))) - ); - assert!(!calls.iter().any(|c| matches!(c, SysCall::PromptRebootTui))); - assert!(!calls.iter().any(|c| matches!(c, SysCall::SpawnReboot))); - } - - // (g) - #[test] - fn register_uninstall_entry_writes_all_seven_values() { - let manifest = InstallManifest { - app_name: "Sample".to_string(), - directories: Vec::new(), - files: Vec::new(), - registry: Vec::new(), - shortcuts: Vec::new(), - scripts: Vec::new(), - purge: PurgeSpec::default(), - support_contact: None, - }; - let runtime = InstallRuntime { - journal_path: PathBuf::from("C:\\fake\\journal.json"), - install_root: Some(PathBuf::from("C:\\Apps\\Sample")), - uninstall_exe_path: Some(PathBuf::from("C:\\Apps\\Sample\\uninstall.exe")), - uninstall_registry_root: RegistryRoot::Hkcu, - uninstall_registry_key: - "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Sample".to_string(), - }; - let install_root = PathBuf::from("C:\\Apps\\Sample"); - let uninstall_exe = PathBuf::from("C:\\Apps\\Sample\\uninstall.exe"); - let sys = MockSys::new(); - register_uninstall_entry( - &manifest, - &runtime, - &install_root, - &uninstall_exe, - &sys, - &quiet_logger(), - ) - .unwrap(); - let writes: Vec<_> = sys - .recorded() - .into_iter() - .filter_map(|c| match c { - SysCall::SetRegistryString { name, .. } => Some(name), - _ => None, - }) - .collect(); - assert_eq!( - writes, - vec![ - "DisplayName", - "Publisher", - "DisplayVersion", - "InstallLocation", - "DisplayIcon", - "UninstallString", - "QuietUninstallString", - ] - ); - } - - // (h) — substitute: run() dispatches Install subcommand to install() through Sys. - #[test] - fn run_install_subcommand_uses_sys_for_registry_writes() { - let temp = TestDir::new("run-install-sys"); - let manifest_path = temp.path().join("Mocked-install.toml"); - fs::write( - &manifest_path, - "app_name = 'Mocked'\n[[registry]]\nkey = 'HKCU\\\\Software\\\\Mocked'\nname = 'Foo'\nvalue = 'Bar'\n", - ) - .unwrap(); - let journal_path = temp.path().join("journal.json"); - let cli = Cli { - json: true, - headed: false, - headless: false, - automation: true, - elevate: false, - command: Some(Commands::Install { - manifest: manifest_path.clone(), - journal: Some(journal_path), - }), - }; - let sys = MockSys::new(); - sys.set_is_elevated(true); - run(cli, &sys, &quiet_logger()).unwrap(); - let writes: Vec<_> = sys - .recorded() - .into_iter() - .filter_map(|c| match c { - SysCall::SetRegistryString { name, value, .. } => Some((name, value)), - _ => None, - }) - .collect(); - assert!(writes.iter().any(|(n, v)| n == "Foo" && v == "Bar")); - } - - // (i) — substitute: install error path emits ui_report_error via run_bundled_installer - // is exercised at the install layer: a missing manifest yields AppError::Io and the - // automation flag suppresses the GUI fail UX. We assert the error propagates. - #[test] - fn install_with_missing_manifest_propagates_error_without_success_ui() { - let sys = MockSys::new(); - let err = install( - Path::new("C:\\does\\not\\exist\\install.toml"), - None, - false, - UiMode::None, - true, - &sys, - None, - &quiet_logger(), - ) - .unwrap_err(); - assert!(matches!(err, AppError::Io(_) | AppError::Message(_))); - } - - #[test] - fn install_failure_triggers_partial_rollback() { - let temp = TestDir::new("install-rollback-test"); - let manifest_path = temp.path().join("RollbackApp-install.toml"); - let dir_to_create = temp.path().join("CreatedDir"); - - // Manifest specifies creating a directory, then copying a non-existent file (which will fail) - fs::write( - &manifest_path, - format!( - "app_name = 'RollbackApp'\ndirectories = {{ paths = ['{}'] }}\n[[files]]\nsource = 'nonexistent.txt'\ndestination = '{}\\\\file.txt'\n", - dir_to_create.display().to_string().replace("\\", "\\\\"), - dir_to_create.display().to_string().replace("\\", "\\\\") - ), - ) - .unwrap(); - - let sys = MockSys::new(); - sys.set_is_elevated(true); - - let err = install( - &manifest_path, - None, - false, - UiMode::None, - true, // automation = true - &sys, - None, - &quiet_logger(), - ) - .unwrap_err(); - - // The installation must have failed - assert!( - err.to_string().contains("nonexistent.txt") - || err.to_string().contains("cannot find") - || err.to_string().contains("system cannot find") - ); - - // The directory created during the partial install must have been rolled back (deleted) - assert!(!dir_to_create.exists()); - } - - #[test] - fn install_cancel_triggers_rollback_and_returns_cancelled() { - let temp = TestDir::new("install-cancel-test"); - let manifest_path = temp.path().join("CancelApp-install.toml"); - let dir_to_create = temp.path().join("CreatedDir"); - let payload = temp.path().join("payload.txt"); - fs::write(&payload, b"data").unwrap(); - - fs::write( - &manifest_path, - format!( - "app_name = 'CancelApp'\ndirectories = {{ paths = ['{}'] }}\n[[files]]\nsource = '{}'\ndestination = '{}\\\\file.txt'\n", - dir_to_create.display().to_string().replace("\\", "\\\\"), - payload.display().to_string().replace("\\", "\\\\"), - dir_to_create.display().to_string().replace("\\", "\\\\") - ), - ) - .unwrap(); - - let sys = MockSys::new(); - sys.set_is_elevated(true); - let sink_calls = sys.install_progress_sink(); - // Trip the cancel flag once the first mutation step has been reported, - // as if the user clicked Cancel between steps. - sys.set_cancel_after_advances(1); - - let err = install( - &manifest_path, - None, - false, - UiMode::None, - false, - &sys, - None, - &quiet_logger(), - ) - .unwrap_err(); - - assert!(matches!(err, AppError::Cancelled)); - assert!(!dir_to_create.exists()); - - let calls = sink_calls.lock().unwrap().clone(); - assert!( - calls - .iter() - .any(|c| matches!(c, SinkCall::Log(m) if m.contains("Cancel requested"))) - ); - assert!(calls.iter().any( - |c| matches!(c, SinkCall::Advance { message, .. } if message.contains("Removing directory")) - )); - let finishes: Vec<_> = calls - .iter() - .filter_map(|c| match c { - SinkCall::Finish { - message, - wait_for_close, - } => Some((message.clone(), *wait_for_close)), - _ => None, - }) - .collect(); - assert_eq!(finishes.len(), 1); - assert!(finishes[0].0.contains("cancelled")); - assert!(finishes[0].1, "non-automation finish must wait for close"); - assert!(!calls.iter().any(|c| matches!(c, SinkCall::Fail { .. }))); - } - - #[test] - fn install_failure_rolls_back_through_same_sink_without_second_session() { - let temp = TestDir::new("install-rollback-same-sink"); - let manifest_path = temp.path().join("SameSinkApp-install.toml"); - let dir_to_create = temp.path().join("CreatedDir"); - - fs::write( - &manifest_path, - format!( - "app_name = 'SameSinkApp'\ndirectories = {{ paths = ['{}'] }}\n[[files]]\nsource = 'nonexistent.txt'\ndestination = '{}\\\\file.txt'\n", - dir_to_create.display().to_string().replace("\\", "\\\\"), - dir_to_create.display().to_string().replace("\\", "\\\\") - ), - ) - .unwrap(); - - let sys = MockSys::new(); - sys.set_is_elevated(true); - let sink_calls = sys.install_progress_sink(); - - let err = install( - &manifest_path, - None, - false, - UiMode::None, - true, - &sys, - None, - &quiet_logger(), - ) - .unwrap_err(); - assert!(!matches!(err, AppError::Cancelled)); - assert!(!dir_to_create.exists()); - - // The rollback must reuse the live session instead of starting a second one. - let start_progress_calls = sys - .recorded() - .iter() - .filter(|c| matches!(c, SysCall::StartProgress { .. })) - .count(); - assert_eq!(start_progress_calls, 1); - - let calls = sink_calls.lock().unwrap().clone(); - assert!(calls.iter().any( - |c| matches!(c, SinkCall::Advance { message, .. } if message.contains("Removing directory")) - )); - let fails = calls - .iter() - .filter(|c| matches!(c, SinkCall::Fail { .. })) - .count(); - assert_eq!(fails, 1, "exactly one terminal fail message"); - assert!(!calls.iter().any(|c| matches!(c, SinkCall::Finish { .. }))); - } - - #[test] - fn install_success_finish_wait_for_close_follows_automation_flag() { - for automation in [false, true] { - let temp = TestDir::new(if automation { - "install-finish-wait-auto" - } else { - "install-finish-wait-manual" - }); - let manifest_path = temp.path().join("FinishApp-install.toml"); - let dir_to_create = temp.path().join("CreatedDir"); - fs::write( - &manifest_path, - format!( - "app_name = 'FinishApp'\ndirectories = {{ paths = ['{}'] }}\n", - dir_to_create.display().to_string().replace("\\", "\\\\") - ), - ) - .unwrap(); - - let sys = MockSys::new(); - sys.set_is_elevated(true); - let sink_calls = sys.install_progress_sink(); - - install( - &manifest_path, - None, - false, - UiMode::None, - automation, - &sys, - None, - &quiet_logger(), - ) - .unwrap(); - - let calls = sink_calls.lock().unwrap().clone(); - let wait_for_close = calls - .iter() - .find_map(|c| match c { - SinkCall::Finish { wait_for_close, .. } => Some(*wait_for_close), - _ => None, - }) - .expect("finish recorded"); - assert_eq!(wait_for_close, !automation); - } - } - - #[test] - fn error_exit_code_maps_cancelled_elevation_and_default() { - assert_eq!(error_exit_code(&AppError::Cancelled), EXIT_CANCELLED); - assert_eq!(error_exit_code(&AppError::CancelledByUser), EXIT_CANCELLED); - assert_eq!( - error_exit_code(&AppError::Message( - "Elevation required to write to HKLM".into() - )), - EXIT_ELEVATION_REQUIRED - ); - assert_eq!( - error_exit_code(&AppError::Message("boom".into())), - EXIT_OPERATION_FAILED - ); - } - - #[test] - fn advance_gui_progress_returns_cancelled_when_sink_requests() { - let handle = Arc::new(Mutex::new(Vec::new())); - let sink = MockProgressSink { - calls: handle.clone(), - cancel_after_advances: Some(0), - }; - let mut progress: Option> = Some(Box::new(sink)); - - let err = advance_gui_progress(&mut progress, 1, 3, "step").unwrap_err(); - assert!(matches!(err, AppError::Cancelled)); - assert!(handle.lock().unwrap().is_empty()); - - progress.as_mut().unwrap().clear_cancel_request(); - advance_gui_progress(&mut progress, 1, 3, "step").unwrap(); - assert_eq!(handle.lock().unwrap().len(), 1); - } - - #[test] - fn uninstall_cancel_mid_run_keeps_journal_and_reports_uninstall_failure() { - let temp = TestDir::new("uninstall-cancel"); - let copied = temp.path().join("copied.bin"); - fs::write(&copied, b"x").unwrap(); - let journal_path = temp.path().join("journal.json"); - let journal = Journal { - app_name: "CancelApp".to_string(), - manifest_path: None, - actions: vec![JournalAction::CopyFile { - source: temp.path().join("source.bin"), - destination: copied.clone(), - }], - purge: PurgeSpec::default(), - support_contact: None, - }; - fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); - - let handle = Arc::new(Mutex::new(Vec::new())); - let sink = MockProgressSink { - calls: handle.clone(), - cancel_after_advances: Some(0), - }; - let mut progress: Option> = Some(Box::new(sink)); - let sys = MockSys::new(); - sys.set_is_elevated(true); - - let err = uninstall( - &journal_path, - false, - UiMode::None, - true, - &sys, - &mut progress, - &quiet_logger(), - false, - ) - .unwrap_err(); - - assert!(matches!(err, AppError::Cancelled)); - assert!( - journal_path.exists(), - "journal must survive a cancelled uninstall" - ); - assert!(copied.exists(), "no actions ran before the cancel tripped"); - let calls = handle.lock().unwrap().clone(); - assert!( - calls - .iter() - .any(|c| matches!(c, SinkCall::Fail { operation, .. } if operation == "uninstall")) - ); - } - - // (j) - #[test] - fn install_emits_set_registry_string_calls_for_each_registry_spec() { - let temp = TestDir::new("install-registry-mock"); - let manifest_path = temp.path().join("RegApp-install.toml"); - fs::write( - &manifest_path, - "app_name = 'RegApp'\n[[registry]]\nkey = 'HKCU\\\\Software\\\\RegApp'\nname = 'Alpha'\nvalue = 'A'\n[[registry]]\nkey = 'HKCU\\\\Software\\\\RegApp'\nname = 'Beta'\nvalue = 'B'\n", - ) - .unwrap(); - let sys = MockSys::new(); - sys.set_is_elevated(true); - install( - &manifest_path, - None, - false, - UiMode::None, - true, - &sys, - None, - &quiet_logger(), - ) - .unwrap(); - let manifest_writes: Vec<_> = sys - .recorded() - .into_iter() - .filter_map(|c| match c { - SysCall::SetRegistryString { name, value, .. } - if name == "Alpha" || name == "Beta" => - { - Some((name, value)) - } - _ => None, - }) - .collect(); - assert_eq!(manifest_writes.len(), 2); - assert!( - manifest_writes - .iter() - .any(|(n, v)| n == "Alpha" && v == "A") - ); - assert!(manifest_writes.iter().any(|(n, v)| n == "Beta" && v == "B")); - } - - // (k) - #[test] - fn uninstall_calls_delete_registry_tree_for_recorded_actions_and_purge() { - let temp = TestDir::new("uninstall-deltree"); - let journal_path = temp.path().join("journal.json"); - let journal = Journal { - app_name: "RegApp".to_string(), - manifest_path: None, - actions: vec![JournalAction::WriteRegistry { - root: RegistryRoot::Hkcu, - subkey: "Software\\RegApp".to_string(), - name: "Alpha".to_string(), - }], - purge: PurgeSpec { - registry_branches: vec!["HKCU\\Software\\Purged".to_string()], - paths: vec![], - }, - support_contact: None, - }; - fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); - let sys = MockSys::new(); - sys.set_is_elevated(true); - uninstall( - &journal_path, - false, - UiMode::None, - true, - &sys, - &mut None, - &quiet_logger(), - false, - ) - .unwrap(); - let trees: Vec<_> = sys - .recorded() - .into_iter() - .filter_map(|c| match c { - SysCall::DeleteRegistryTree { subkey, .. } => Some(subkey), - _ => None, - }) - .collect(); - assert!(trees.iter().any(|s| s == "Software\\RegApp")); - assert!(trees.iter().any(|s| s == "Software\\Purged")); - } - - // (l) - #[test] - fn uninstall_calls_remove_file_with_fallback_for_copy_actions_and_shortcuts() { - let temp = TestDir::new("uninstall-rmfile"); - let copied = temp.path().join("copied.bin"); - let shortcut = temp.path().join("Shortcut.lnk"); - fs::write(&copied, b"x").unwrap(); - fs::write(&shortcut, b"x").unwrap(); - let journal_path = temp.path().join("journal.json"); - let journal = Journal { - app_name: "FileApp".to_string(), - manifest_path: None, - actions: vec![ - JournalAction::CopyFile { - source: temp.path().join("source.bin"), - destination: copied.clone(), - }, - JournalAction::CreateShortcut { - path: shortcut.clone(), - }, - ], - purge: PurgeSpec::default(), - support_contact: None, - }; - fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); - let sys = MockSys::new(); - sys.set_is_elevated(true); - uninstall( - &journal_path, - false, - UiMode::None, - true, - &sys, - &mut None, - &quiet_logger(), - false, - ) - .unwrap(); - let removed: Vec<_> = sys - .recorded() - .into_iter() - .filter_map(|c| match c { - SysCall::RemoveFileWithFallback(p) => Some(p), - _ => None, - }) - .collect(); - assert!(removed.iter().any(|p| p == &copied)); - assert!(removed.iter().any(|p| p == &shortcut)); - } - - // (m): the self-delete branch is triggered when a CopyFile destination - // matches the current exe — uninstall() then calls spawn_cleanup_helper. - #[test] - fn uninstall_defers_self_delete_to_spawn_cleanup_helper() { - let temp = TestDir::new("uninstall-self"); - let current_exe = std::env::current_exe().unwrap(); - let journal_path = temp.path().join("journal.json"); - let journal = Journal { - app_name: "SelfApp".to_string(), - manifest_path: None, - actions: vec![JournalAction::CopyFile { - source: temp.path().join("source.exe"), - destination: current_exe.clone(), - }], - purge: PurgeSpec::default(), - support_contact: None, - }; - fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); - let sys = MockSys::new(); - sys.set_is_elevated(true); - uninstall( - &journal_path, - false, - UiMode::None, - true, - &sys, - &mut None, - &quiet_logger(), - false, - ) - .unwrap(); - let helper = sys - .recorded() - .into_iter() - .find(|c| matches!(c, SysCall::SpawnCleanupHelper { .. })) - .expect("expected SpawnCleanupHelper recorded"); - match helper { - SysCall::SpawnCleanupHelper { - target_exe, - app_name, - .. - } => { - assert!(same_path(&target_exe, ¤t_exe)); - assert_eq!(app_name, "SelfApp"); - } - _ => unreachable!(), - } - } - - // (n) - #[test] - fn progress_sink_mock_records_calls_through_advance_log_finish_fail() { - let recorder = MockProgressSink::new(); - let handle = recorder.handle(); - let mut sink: Box = Box::new(recorder); - sink.advance(2, 5, "step 2").unwrap(); - sink.log("note").unwrap(); - sink.finish("done", true).unwrap(); - sink.fail( - "App", - "install", - "boom", - "io error", - serde_json::json!({"k":"v"}), - true, - ) - .unwrap(); - let calls = handle.lock().unwrap().clone(); - assert_eq!(calls.len(), 4); - assert!(matches!( - &calls[0], - SinkCall::Advance { current_step: 2, total_steps: 5, message } if message == "step 2" - )); - assert!(matches!(&calls[1], SinkCall::Log(s) if s == "note")); - assert!(matches!( - &calls[2], - SinkCall::Finish { message, wait_for_close } if message == "done" && *wait_for_close - )); - assert!(matches!( - &calls[3], - SinkCall::Fail { app_name, operation, error, wait_for_close, .. } - if app_name == "App" && operation == "install" && error == "io error" && *wait_for_close - )); - } - - fn sample_manifest() -> InstallManifest { - InstallManifest { - app_name: "Sample App".to_string(), - directories: vec!["C:\\Apps\\Sample\\bin".to_string()], - files: vec![FileSpec { - source: "payload\\app.exe".to_string(), - destination: "C:\\Apps\\Sample\\app.exe".to_string(), - }], - registry: vec![RegistrySpec { - key: "HKCU\\Software\\Sample".to_string(), - name: "InstallLocation".to_string(), - value: "C:\\Apps\\Sample".to_string(), - }], - shortcuts: vec![ShortcutSpec { - path: "C:\\Users\\Public\\Desktop\\Sample.lnk".to_string(), - target: "C:\\Apps\\Sample\\app.exe".to_string(), - arguments: None, - working_directory: None, - description: None, - }], - scripts: vec![ScriptSpec { - command: "post-install.cmd".to_string(), - args: vec!["--ok".to_string()], - working_directory: None, - }], - purge: PurgeSpec { - registry_branches: vec![], - paths: vec!["C:\\Apps\\Sample".to_string()], - }, - support_contact: None, - } - } - - fn write_embedded_payload(exe: &Path, payload: &[u8]) { - let mut bytes = payload.to_vec(); - bytes.extend_from_slice(&(payload.len() as u64).to_le_bytes()); - bytes.extend_from_slice(EMBEDDED_MAGIC); - fs::write(exe, bytes).unwrap(); - } - - fn quiet_logger() -> Logger { - Logger { - json: false, - quiet: true, - } - } + std::process::exit(covenant_setup::run()); } diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..1b4fa14 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,374 @@ +use crate::AppError; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +#[derive(Debug, Deserialize)] +pub(crate) struct InstallManifest { + pub(crate) app_name: String, + #[serde(default, deserialize_with = "deserialize_directory_paths")] + pub(crate) directories: Vec, + #[serde(default)] + pub(crate) files: Vec, + #[serde(default)] + pub(crate) registry: Vec, + #[serde(default)] + pub(crate) shortcuts: Vec, + #[serde(default)] + pub(crate) scripts: Vec, + #[serde(default)] + pub(crate) purge: PurgeSpec, + #[serde(default)] + pub(crate) support_contact: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct PurgeSpec { + #[serde(default)] + pub(crate) registry_branches: Vec, + #[serde(default)] + pub(crate) paths: Vec, +} + +#[derive(Debug, Deserialize)] +struct DirectoryPaths { + #[serde(default)] + paths: Vec, +} + +fn deserialize_directory_paths<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(DirectoryPaths::deserialize(deserializer)?.paths) +} + +#[derive(Debug, Deserialize)] +pub(crate) struct FileSpec { + pub(crate) source: String, + pub(crate) destination: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct RegistrySpec { + pub(crate) key: String, + pub(crate) name: String, + pub(crate) value: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ShortcutSpec { + pub(crate) path: String, + pub(crate) target: String, + #[serde(default)] + pub(crate) arguments: Option, + #[serde(default)] + pub(crate) working_directory: Option, + #[serde(default)] + pub(crate) description: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ScriptSpec { + pub(crate) command: String, + #[serde(default)] + pub(crate) args: Vec, + #[serde(default)] + pub(crate) working_directory: Option, +} + +pub(crate) fn read_install_manifest(manifest_path: &Path) -> Result { + let manifest: InstallManifest = toml::from_str(&fs::read_to_string(manifest_path)?)?; + enforce_manifest_file_name(manifest_path, &manifest)?; + enforce_manifest_field_spacing(&manifest)?; + Ok(manifest) +} + +/// Packaging-time rule only: install must accept manifests from any +/// directory because bundled installs extract under %TEMP%, which contains +/// spaces for user profiles like `C:\Users\First Last`. +pub(crate) fn enforce_manifest_path_spacing(manifest_path: &Path) -> Result<(), AppError> { + let manifest_path_text = manifest_path.to_string_lossy(); + if contains_whitespace(&manifest_path_text) { + return Err(AppError::Message(format!( + "Manifest path cannot contain spaces: {}", + manifest_path.display() + ))); + } + + Ok(()) +} + +pub(crate) fn enforce_manifest_file_name( + manifest_path: &Path, + manifest: &InstallManifest, +) -> Result<(), AppError> { + let expected = expected_manifest_file_name(&manifest.app_name); + let actual = manifest_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| AppError::Message("Manifest path must include a file name".into()))?; + if actual != expected { + return Err(AppError::Message(format!( + "Manifest file name must be '{expected}' for app_name '{}'; got '{actual}'", + manifest.app_name + ))); + } + + Ok(()) +} + +pub(crate) fn enforce_manifest_field_spacing(manifest: &InstallManifest) -> Result<(), AppError> { + for (index, directory) in manifest.directories.iter().enumerate() { + enforce_no_whitespace(&format!("directories.paths[{index}]"), directory)?; + } + + for (index, file) in manifest.files.iter().enumerate() { + enforce_no_whitespace(&format!("files[{index}].source"), &file.source)?; + enforce_no_whitespace(&format!("files[{index}].destination"), &file.destination)?; + } + + for (index, registry) in manifest.registry.iter().enumerate() { + enforce_no_whitespace(&format!("registry[{index}].key"), ®istry.key)?; + enforce_no_whitespace(&format!("registry[{index}].name"), ®istry.name)?; + enforce_no_whitespace(&format!("registry[{index}].value"), ®istry.value)?; + } + + for (index, shortcut) in manifest.shortcuts.iter().enumerate() { + enforce_no_whitespace(&format!("shortcuts[{index}].path"), &shortcut.path)?; + enforce_no_whitespace(&format!("shortcuts[{index}].target"), &shortcut.target)?; + enforce_optional_no_whitespace( + &format!("shortcuts[{index}].arguments"), + shortcut.arguments.as_deref(), + )?; + enforce_optional_no_whitespace( + &format!("shortcuts[{index}].working_directory"), + shortcut.working_directory.as_deref(), + )?; + } + + for (index, script) in manifest.scripts.iter().enumerate() { + enforce_no_whitespace(&format!("scripts[{index}].command"), &script.command)?; + for (arg_index, arg) in script.args.iter().enumerate() { + enforce_no_whitespace(&format!("scripts[{index}].args[{arg_index}]"), arg)?; + } + enforce_optional_no_whitespace( + &format!("scripts[{index}].working_directory"), + script.working_directory.as_deref(), + )?; + } + + for (index, branch) in manifest.purge.registry_branches.iter().enumerate() { + enforce_no_whitespace(&format!("purge.registry_branches[{index}]"), branch)?; + } + + for (index, path) in manifest.purge.paths.iter().enumerate() { + enforce_no_whitespace(&format!("purge.paths[{index}]"), path)?; + } + + Ok(()) +} + +fn enforce_optional_no_whitespace(field: &str, value: Option<&str>) -> Result<(), AppError> { + if let Some(value) = value { + enforce_no_whitespace(field, value)?; + } + Ok(()) +} + +fn enforce_no_whitespace(field: &str, value: &str) -> Result<(), AppError> { + if contains_whitespace(value) { + return Err(AppError::Message(format!( + "Manifest field '{field}' cannot contain spaces or other whitespace: {value}" + ))); + } + + Ok(()) +} + +fn contains_whitespace(value: &str) -> bool { + value.chars().any(char::is_whitespace) +} + +pub(crate) fn expected_manifest_file_name(app_name: &str) -> String { + let name: String = app_name + .trim() + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect(); + if name.is_empty() { + "install.toml".to_string() + } else { + format!("{name}-install.toml") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + + #[test] + fn manifest_deserializes_grouped_directories() { + let manifest: InstallManifest = toml::from_str( + r#" +app_name = 'Grouped App' + +[directories] +paths = [ + '{LocalAppData}\Grouped', + '{LocalAppData}\Grouped\bin', +] +"#, + ) + .unwrap(); + + assert_eq!(manifest.directories.len(), 2); + assert_eq!(manifest.directories[0], r"{LocalAppData}\Grouped"); + assert_eq!(manifest.directories[1], r"{LocalAppData}\Grouped\bin"); + } + + #[test] + fn manifest_rejects_legacy_directory_tables() { + let result = toml::from_str::( + r#" +app_name = 'Legacy App' + +[[directories]] +path = '{LocalAppData}\Legacy' + +[[directories]] +path = '{LocalAppData}\Legacy\bin' +"#, + ); + + assert!(result.is_err()); + } + + #[test] + fn manifest_file_name_must_match_app_name_convention() { + let manifest = InstallManifest { + app_name: "Sample App".to_string(), + directories: Vec::new(), + files: Vec::new(), + registry: Vec::new(), + shortcuts: Vec::new(), + scripts: Vec::new(), + purge: PurgeSpec::default(), + support_contact: None, + }; + + enforce_manifest_file_name(Path::new("SampleApp-install.toml"), &manifest).unwrap(); + + let err = enforce_manifest_file_name(Path::new("install.toml"), &manifest) + .unwrap_err() + .to_string(); + assert!(err.contains("SampleApp-install.toml")); + + // Only the file name is constrained at install time; the directory may + // contain spaces (bundled installs extract under %TEMP%). + enforce_manifest_file_name( + Path::new("manifest folder\\SampleApp-install.toml"), + &manifest, + ) + .unwrap(); + } + + #[test] + fn manifest_path_spacing_is_enforced_only_for_packaging() { + enforce_manifest_path_spacing(Path::new("C:\\work\\SampleApp-install.toml")).unwrap(); + + let err = + enforce_manifest_path_spacing(Path::new("manifest folder\\SampleApp-install.toml")) + .unwrap_err() + .to_string(); + assert!(err.contains("cannot contain spaces")); + } + + #[test] + fn read_install_manifest_accepts_spaces_in_parent_directory() { + let temp = TestDir::new("manifest-spaced-parent"); + let spaced_dir = temp.path().join("User Name"); + fs::create_dir_all(&spaced_dir).unwrap(); + let manifest_path = spaced_dir.join("SpacedDir-install.toml"); + fs::write(&manifest_path, "app_name = 'Spaced Dir'\n").unwrap(); + + read_install_manifest(&manifest_path).unwrap(); + } + + #[test] + fn manifest_field_spacing_allows_only_app_name_and_description_spaces() { + let mut manifest = InstallManifest { + app_name: "Display Name With Spaces".to_string(), + directories: vec!["{LocalAppData}\\NoSpaces".to_string()], + files: vec![FileSpec { + source: "payload\\app.exe".to_string(), + destination: "{LocalAppData}\\NoSpaces\\app.exe".to_string(), + }], + registry: vec![RegistrySpec { + key: "HKCU\\Software\\NoSpaces".to_string(), + name: "InstallRoot".to_string(), + value: "{LocalAppData}\\NoSpaces".to_string(), + }], + shortcuts: vec![ShortcutSpec { + path: "{Desktop}\\NoSpaces.lnk".to_string(), + target: "{LocalAppData}\\NoSpaces\\app.exe".to_string(), + arguments: Some("--profile=default".to_string()), + working_directory: Some("{LocalAppData}\\NoSpaces".to_string()), + description: Some("Description can have spaces".to_string()), + }], + scripts: vec![ScriptSpec { + command: "powershell.exe".to_string(), + args: vec![ + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + "-File".to_string(), + "payload\\post_install.ps1".to_string(), + ], + working_directory: Some("{LocalAppData}\\NoSpaces".to_string()), + }], + purge: PurgeSpec { + registry_branches: vec!["HKCU\\Software\\NoSpaces".to_string()], + paths: vec!["{LocalAppData}\\NoSpaces".to_string()], + }, + support_contact: None, + }; + + enforce_manifest_field_spacing(&manifest).unwrap(); + + manifest.scripts[0].args[3] = "payload\\post install.ps1".to_string(); + let err = enforce_manifest_field_spacing(&manifest) + .unwrap_err() + .to_string(); + assert!(err.contains("scripts[0].args[3]")); + assert!(err.contains("cannot contain spaces")); + } + + #[test] + fn read_install_manifest_rejects_space_in_manifest_field() { + let temp = TestDir::new("manifest-field-spaces"); + let manifest_path = temp.path().join("SpaceAllowed-install.toml"); + fs::write( + &manifest_path, + r#" +app_name = 'Space Allowed' + +[directories] +paths = ['{LocalAppData}\Bad Path'] + +[[shortcuts]] +path = '{Desktop}\NoSpaces.lnk' +target = '{LocalAppData}\NoSpaces\app.exe' +description = 'Description can have spaces' +"#, + ) + .unwrap(); + + let err = read_install_manifest(&manifest_path) + .unwrap_err() + .to_string(); + + assert!(err.contains("directories.paths[0]")); + assert!(err.contains("cannot contain spaces")); + } +} diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 0000000..0aacbc3 --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,367 @@ +use crate::sys::Sys; +use crate::ui::{self, ProgressSink}; +use crate::{AppError, Logger, UiMode, trace_event, ui_mode_name}; +use serde_json::json; +use std::io::{self, Write}; +use std::path::Path; +use std::process; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub(crate) static FAILURE_UX_SHOWN: AtomicBool = AtomicBool::new(false); + +pub(crate) struct TuiProgress { + active: Arc, + handle: Option>, +} + +impl TuiProgress { + fn start(label: String) -> Self { + let active = Arc::new(AtomicBool::new(true)); + let active_thread = active.clone(); + let handle = thread::spawn(move || { + let frames = ["", ".", "..", "...", "....", "....."]; + let mut index = 0usize; + while active_thread.load(Ordering::Relaxed) { + let frame = frames[index % frames.len()]; + print!("\r{label}{frame} "); + let _ = io::stdout().flush(); + thread::sleep(Duration::from_millis(500)); + index = (index + 1) % frames.len(); + } + print!("\r{}\r", " ".repeat(label.len() + 8)); + let _ = io::stdout().flush(); + }); + Self { + active, + handle: Some(handle), + } + } +} + +impl Drop for TuiProgress { + fn drop(&mut self) { + self.active.store(false, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +pub(crate) fn start_tui_progress(ui_mode: UiMode, label: String) -> Option { + if ui_mode == UiMode::Tui { + Some(TuiProgress::start(label)) + } else { + None + } +} + +pub(crate) fn start_gui_progress( + ui_mode: UiMode, + sys: &dyn Sys, + title: &str, + app_name: Option<&str>, + install_root: Option<&Path>, + branding_image: Option<&str>, + show_welcome: bool, + total_steps: usize, + logger: &Logger, +) -> Result>, AppError> { + trace_event( + "gui_progress_start", + json!({ + "ui_mode": ui_mode_name(ui_mode), + "title": title, + "total_steps": total_steps.max(1), + "show_welcome": show_welcome + }), + ); + if let Some(sink) = sys.start_progress(ui_mode, title, total_steps.max(1))? { + return Ok(Some(sink)); + } + if ui_mode == UiMode::Gui { + Ok(Some(Box::new(ui::GuiProgress::start( + title, + app_name, + install_root + .map(|p| p.to_string_lossy().into_owned()) + .as_deref(), + branding_image, + show_welcome, + total_steps.max(1), + logger, + )?))) + } else { + Ok(None) + } +} + +pub(crate) fn advance_gui_progress( + gui_progress: &mut Option>, + current_step: usize, + total_steps: usize, + message: &str, +) -> Result<(), AppError> { + if gui_progress + .as_mut() + .is_some_and(|progress| progress.cancel_requested()) + { + trace_event( + "cancel_request_detected", + json!({"current_step": current_step}), + ); + return Err(AppError::Cancelled); + } + trace_event( + "progress", + json!({"current_step": current_step, "message": message}), + ); + if let Some(progress) = gui_progress.as_mut() { + progress.advance(current_step, total_steps, message)?; + } + Ok(()) +} + +pub(crate) fn advance_gui_progress_step( + gui_progress: &mut Option>, + current_step: &mut usize, + total_steps: usize, + message: &str, +) -> Result<(), AppError> { + *current_step += 1; + advance_gui_progress(gui_progress, *current_step, total_steps, message) +} + +pub(crate) fn finish_gui_progress( + gui_progress: &mut Option>, + message: &str, + wait_for_close: bool, +) -> Result<(), AppError> { + trace_event("progress_finish", json!({"message": message})); + if let Some(progress) = gui_progress.as_mut() { + progress.finish(message, wait_for_close)?; + } + Ok(()) +} + +pub(crate) fn fail_gui_progress( + gui_progress: &mut Option>, + app_name: &str, + operation: &str, + err: &AppError, + support_contact: Option<&str>, + wait_for_close: bool, +) -> Result<(), AppError> { + let message = format!("Error: program {app_name} failed to {operation} completely!"); + trace_event( + "progress_fail", + json!({"app_name": app_name, "operation": operation, "message": message, "error": err.to_string()}), + ); + if let Some(progress) = gui_progress.as_mut() { + progress.fail( + app_name, + operation, + &message, + &err.to_string(), + error_errata(app_name, operation, err, support_contact), + wait_for_close, + )?; + mark_failure_ux_shown(); + } + Ok(()) +} + +pub(crate) fn mark_failure_ux_shown() { + FAILURE_UX_SHOWN.store(true, Ordering::Relaxed); +} + +pub(crate) fn failure_ux_shown() -> bool { + FAILURE_UX_SHOWN.load(Ordering::Relaxed) +} + +pub(crate) fn error_errata( + app_name: &str, + operation: &str, + err: &AppError, + support_contact: Option<&str>, +) -> serde_json::Value { + let timestamp_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis()); + json!({ + "schema": "covenant_setup_errata_v1", + "app_name": app_name, + "operation": operation, + "timestamp_unix_ms": timestamp_unix_ms, + "support_contact": support_contact, + "error": { + "message": err.to_string(), + "debug": format!("{err:?}"), + }, + "process": { + "pid": process::id(), + "exe": std::env::current_exe().ok().map(|path| path.display().to_string()), + "current_dir": std::env::current_dir().ok().map(|path| path.display().to_string()), + "args": std::env::args_os() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(), + "trace_dir": std::env::var_os("COVENANT_SETUP_TRACE_DIR") + .map(|path| path.to_string_lossy().to_string()), + } + }) +} + +pub(crate) fn append_gui_shell_output( + gui_progress: &mut Option>, + bytes: &[u8], +) -> Result<(), AppError> { + if bytes.is_empty() { + return Ok(()); + } + if let Some(progress) = gui_progress.as_mut() { + let text = String::from_utf8_lossy(bytes); + for line in text.lines().filter(|line| !line.trim().is_empty()) { + trace_event("script_output", json!({"line": line})); + progress.log(line)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + use crate::*; + + #[test] + fn tui_progress_can_start_and_stop() { + let progress = start_tui_progress(UiMode::Tui, "Testing ".to_string()); + assert!(progress.is_some()); + drop(progress); + assert!(start_tui_progress(UiMode::None, "Testing ".to_string()).is_none()); + } + + #[test] + fn progress_helpers_are_noops_without_gui_progress() { + let mut progress: Option> = None; + let mut step = 0; + + assert!( + start_gui_progress( + UiMode::None, + &WinSys, + "No UI", + None, + None, + None, + false, + 0, + &quiet_logger() + ) + .unwrap() + .is_none() + ); + advance_gui_progress(&mut progress, 1, 3, "step").unwrap(); + advance_gui_progress_step(&mut progress, &mut step, 3, "next").unwrap(); + finish_gui_progress(&mut progress, "done", false).unwrap(); + fail_gui_progress( + &mut progress, + "App", + "install", + &AppError::Message("boom".to_string()), + None, + false, + ) + .unwrap(); + append_gui_shell_output(&mut progress, b"").unwrap(); + append_gui_shell_output(&mut progress, b"line 1\n\nline 2\n").unwrap(); + + assert_eq!(step, 1); + } + + #[test] + fn failure_ux_marker_tracks_whether_failure_was_shown() { + FAILURE_UX_SHOWN.store(false, Ordering::Relaxed); + assert!(!failure_ux_shown()); + + mark_failure_ux_shown(); + assert!(failure_ux_shown()); + + FAILURE_UX_SHOWN.store(false, Ordering::Relaxed); + } + + #[test] + fn error_errata_contains_operation_error_and_process_context() { + let errata = error_errata( + "Errata App", + "install", + &AppError::Message("boom".into()), + Some("support@example.com"), + ); + + assert_eq!(errata["schema"], "covenant_setup_errata_v1"); + assert_eq!(errata["app_name"], "Errata App"); + assert_eq!(errata["operation"], "install"); + assert_eq!(errata["error"]["message"], "boom"); + assert_eq!(errata["support_contact"], "support@example.com"); + assert!(errata["process"]["pid"].as_u64().is_some()); + assert!(errata["process"]["args"].as_array().is_some()); + } + + #[test] + fn advance_gui_progress_returns_cancelled_when_sink_requests() { + let handle = Arc::new(Mutex::new(Vec::new())); + let sink = MockProgressSink { + calls: handle.clone(), + cancel_after_advances: Some(0), + }; + let mut progress: Option> = Some(Box::new(sink)); + + let err = advance_gui_progress(&mut progress, 1, 3, "step").unwrap_err(); + assert!(matches!(err, AppError::Cancelled)); + assert!(handle.lock().unwrap().is_empty()); + + progress.as_mut().unwrap().clear_cancel_request(); + advance_gui_progress(&mut progress, 1, 3, "step").unwrap(); + assert_eq!(handle.lock().unwrap().len(), 1); + } + + #[test] + fn progress_sink_mock_records_calls_through_advance_log_finish_fail() { + let recorder = MockProgressSink::new(); + let handle = recorder.handle(); + let mut sink: Box = Box::new(recorder); + sink.advance(2, 5, "step 2").unwrap(); + sink.log("note").unwrap(); + sink.finish("done", true).unwrap(); + sink.fail( + "App", + "install", + "boom", + "io error", + serde_json::json!({"k":"v"}), + true, + ) + .unwrap(); + let calls = handle.lock().unwrap().clone(); + assert_eq!(calls.len(), 4); + assert!(matches!( + &calls[0], + SinkCall::Advance { current_step: 2, total_steps: 5, message } if message == "step 2" + )); + assert!(matches!(&calls[1], SinkCall::Log(s) if s == "note")); + assert!(matches!( + &calls[2], + SinkCall::Finish { message, wait_for_close } if message == "done" && *wait_for_close + )); + assert!(matches!( + &calls[3], + SinkCall::Fail { app_name, operation, error, wait_for_close, .. } + if app_name == "App" && operation == "install" && error == "io error" && *wait_for_close + )); + } +} diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..523eb92 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,454 @@ +//! Shared test doubles and fixtures for the per-module unit suites. +//! Relocated verbatim from the former monolithic test module. +//! +//! Re-exports the crate prelude plus the std items the doubles and the +//! distributed test modules rely on, so each `mod tests` only needs +//! `use super::*; use crate::test_support::*;`. + +pub(crate) use crate::*; +pub(crate) use std::fs; +pub(crate) use std::path::{Path, PathBuf}; +pub(crate) use std::process; +pub(crate) use std::sync::{Arc, Mutex}; +pub(crate) use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) struct TestDir { + path: PathBuf, +} + +impl TestDir { + pub(crate) fn new(name: &str) -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "covenant-setup-test-{name}-{}-{unique}", + process::id() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +// ------------------------------------------------------------------------- +// Mock infrastructure for the Sys trait + ProgressSink trait. These mocks +// record every boundary call the orchestration code makes so tests can +// assert on exact sequences without spawning Win32 / process / GUI side +// effects. +// ------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SysCall { + IsElevated, + RelaunchAsAdmin, + SpawnReboot, + PromptRebootTui, + SpawnCleanupHelper { + target_exe: PathBuf, + install_root: Option, + app_name: String, + ui_mode: UiMode, + automation: bool, + json: bool, + }, + ScheduleHelperSelfCleanup, + SetRegistryString { + root: RegistryRoot, + subkey: String, + name: String, + value: String, + }, + DeleteRegistryTree { + root: RegistryRoot, + subkey: String, + }, + HasEmbeddedBundle, + UiAvailable, + UiReportError(String), + UiReportUninstallSuccess(String), + UiPromptUninstallReboot(String), + RemoveFileWithFallback(PathBuf), + StartProgress { + ui_mode: UiMode, + title: String, + total_steps: usize, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SinkCall { + Advance { + current_step: usize, + total_steps: usize, + message: String, + }, + Log(String), + Finish { + message: String, + wait_for_close: bool, + }, + Fail { + app_name: String, + operation: String, + message: String, + error: String, + wait_for_close: bool, + }, +} + +#[derive(Default)] +pub(crate) struct MockProgressSink { + pub(crate) calls: Arc>>, + // When Some(n), cancel_requested() reports true once n Advance calls + // have been recorded; clear_cancel_request() resets it to None. + pub(crate) cancel_after_advances: Option, +} + +impl MockProgressSink { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn handle(&self) -> Arc>> { + self.calls.clone() + } + + pub(crate) fn recorded_advances(&self) -> usize { + self.calls + .lock() + .unwrap() + .iter() + .filter(|call| matches!(call, SinkCall::Advance { .. })) + .count() + } +} + +impl ProgressSink for MockProgressSink { + fn advance( + &mut self, + current_step: usize, + total_steps: usize, + message: &str, + ) -> Result<(), AppError> { + self.calls.lock().unwrap().push(SinkCall::Advance { + current_step, + total_steps, + message: message.to_string(), + }); + Ok(()) + } + + fn log(&mut self, message: &str) -> Result<(), AppError> { + self.calls + .lock() + .unwrap() + .push(SinkCall::Log(message.to_string())); + Ok(()) + } + + fn finish(&mut self, message: &str, wait_for_close: bool) -> Result<(), AppError> { + self.calls.lock().unwrap().push(SinkCall::Finish { + message: message.to_string(), + wait_for_close, + }); + Ok(()) + } + + fn cancel_requested(&mut self) -> bool { + self.cancel_after_advances + .is_some_and(|threshold| self.recorded_advances() >= threshold) + } + + fn clear_cancel_request(&mut self) { + self.cancel_after_advances = None; + } + + fn fail( + &mut self, + app_name: &str, + operation: &str, + message: &str, + error: &str, + _errata: serde_json::Value, + wait_for_close: bool, + ) -> Result<(), AppError> { + self.calls.lock().unwrap().push(SinkCall::Fail { + app_name: app_name.to_string(), + operation: operation.to_string(), + message: message.to_string(), + error: error.to_string(), + wait_for_close, + }); + Ok(()) + } +} + +#[derive(Default)] +pub(crate) struct MockSys { + calls: Mutex>, + is_elevated: Mutex, + ui_available: Mutex, + ui_prompt_uninstall_reboot: Mutex, + schedule_helper_self_cleanup: Mutex, + prompt_reboot_tui: Mutex, + has_embedded_bundle: Mutex, + progress_sink_calls: Mutex>>>>, + progress_sink_cancel_after: Mutex>, +} + +#[allow(dead_code)] +impl MockSys { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn recorded(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + + pub(crate) fn set_is_elevated(&self, value: bool) { + *self.is_elevated.lock().unwrap() = value; + } + + pub(crate) fn set_ui_available(&self, value: bool) { + *self.ui_available.lock().unwrap() = value; + } + + pub(crate) fn set_ui_prompt_uninstall_reboot(&self, value: bool) { + *self.ui_prompt_uninstall_reboot.lock().unwrap() = value; + } + + pub(crate) fn set_schedule_helper_self_cleanup(&self, value: bool) { + *self.schedule_helper_self_cleanup.lock().unwrap() = value; + } + + pub(crate) fn set_prompt_reboot_tui(&self, value: bool) { + *self.prompt_reboot_tui.lock().unwrap() = value; + } + + pub(crate) fn install_progress_sink(&self) -> Arc>> { + let sink = MockProgressSink::new(); + let handle = sink.handle(); + *self.progress_sink_calls.lock().unwrap() = Some(handle.clone()); + // Box and stash a fresh sink each call to start_progress; use the + // shared handle so tests can read the recorded calls. + handle + } + + pub(crate) fn set_cancel_after_advances(&self, threshold: usize) { + *self.progress_sink_cancel_after.lock().unwrap() = Some(threshold); + } +} + +impl Sys for MockSys { + fn is_elevated(&self, _logger: &Logger) -> Result { + self.calls.lock().unwrap().push(SysCall::IsElevated); + Ok(*self.is_elevated.lock().unwrap()) + } + + fn relaunch_as_admin(&self, _logger: &Logger) -> Result<(), AppError> { + self.calls.lock().unwrap().push(SysCall::RelaunchAsAdmin); + Ok(()) + } + + fn spawn_reboot(&self, _logger: &Logger) -> Result<(), AppError> { + self.calls.lock().unwrap().push(SysCall::SpawnReboot); + Ok(()) + } + + fn prompt_reboot_tui(&self) -> Result { + self.calls.lock().unwrap().push(SysCall::PromptRebootTui); + Ok(*self.prompt_reboot_tui.lock().unwrap()) + } + + fn spawn_cleanup_helper( + &self, + target_exe: &Path, + install_root: Option<&Path>, + app_name: &str, + ui_mode: UiMode, + automation: bool, + json: bool, + _logger: &Logger, + ) -> Result<(), AppError> { + self.calls + .lock() + .unwrap() + .push(SysCall::SpawnCleanupHelper { + target_exe: target_exe.to_path_buf(), + install_root: install_root.map(Path::to_path_buf), + app_name: app_name.to_string(), + ui_mode, + automation, + json, + }); + Ok(()) + } + + fn schedule_helper_self_cleanup(&self, _logger: &Logger) -> Result { + self.calls + .lock() + .unwrap() + .push(SysCall::ScheduleHelperSelfCleanup); + Ok(*self.schedule_helper_self_cleanup.lock().unwrap()) + } + + fn set_registry_string( + &self, + root: RegistryRoot, + subkey: &str, + name: &str, + value: &str, + _logger: &Logger, + ) -> Result<(), AppError> { + self.calls.lock().unwrap().push(SysCall::SetRegistryString { + root, + subkey: subkey.to_string(), + name: name.to_string(), + value: value.to_string(), + }); + Ok(()) + } + + fn delete_registry_tree( + &self, + root: RegistryRoot, + subkey: &str, + _logger: &Logger, + ) -> Result<(), AppError> { + self.calls + .lock() + .unwrap() + .push(SysCall::DeleteRegistryTree { + root, + subkey: subkey.to_string(), + }); + Ok(()) + } + + fn has_embedded_bundle(&self) -> bool { + self.calls.lock().unwrap().push(SysCall::HasEmbeddedBundle); + *self.has_embedded_bundle.lock().unwrap() + } + + fn ui_available(&self) -> bool { + self.calls.lock().unwrap().push(SysCall::UiAvailable); + *self.ui_available.lock().unwrap() + } + + fn ui_report_error(&self, message: &str) -> Result<(), AppError> { + self.calls + .lock() + .unwrap() + .push(SysCall::UiReportError(message.to_string())); + Ok(()) + } + + fn ui_report_uninstall_success(&self, app_name: &str) -> Result<(), AppError> { + self.calls + .lock() + .unwrap() + .push(SysCall::UiReportUninstallSuccess(app_name.to_string())); + Ok(()) + } + + fn ui_prompt_uninstall_reboot(&self, app_name: &str) -> Result { + self.calls + .lock() + .unwrap() + .push(SysCall::UiPromptUninstallReboot(app_name.to_string())); + Ok(*self.ui_prompt_uninstall_reboot.lock().unwrap()) + } + + fn remove_file_with_fallback(&self, path: &Path, _logger: &Logger) -> Result<(), AppError> { + self.calls + .lock() + .unwrap() + .push(SysCall::RemoveFileWithFallback(path.to_path_buf())); + // Best-effort delete the real file so subsequent fs::read_dir checks + // in cleanup() see the directory as empty. + let _ = std::fs::remove_file(path); + Ok(()) + } + + fn start_progress( + &self, + ui_mode: UiMode, + title: &str, + total_steps: usize, + ) -> Result>, AppError> { + self.calls.lock().unwrap().push(SysCall::StartProgress { + ui_mode, + title: title.to_string(), + total_steps, + }); + // Only inject a recording sink when test code explicitly opted in. + if let Some(handle) = self.progress_sink_calls.lock().unwrap().clone() { + let sink = MockProgressSink { + calls: handle.clone(), + cancel_after_advances: *self.progress_sink_cancel_after.lock().unwrap(), + }; + Ok(Some(Box::new(sink) as Box)) + } else { + Ok(None) + } + } +} + +pub(crate) fn sample_manifest() -> InstallManifest { + InstallManifest { + app_name: "Sample App".to_string(), + directories: vec!["C:\\Apps\\Sample\\bin".to_string()], + files: vec![FileSpec { + source: "payload\\app.exe".to_string(), + destination: "C:\\Apps\\Sample\\app.exe".to_string(), + }], + registry: vec![RegistrySpec { + key: "HKCU\\Software\\Sample".to_string(), + name: "InstallLocation".to_string(), + value: "C:\\Apps\\Sample".to_string(), + }], + shortcuts: vec![ShortcutSpec { + path: "C:\\Users\\Public\\Desktop\\Sample.lnk".to_string(), + target: "C:\\Apps\\Sample\\app.exe".to_string(), + arguments: None, + working_directory: None, + description: None, + }], + scripts: vec![ScriptSpec { + command: "post-install.cmd".to_string(), + args: vec!["--ok".to_string()], + working_directory: None, + }], + purge: PurgeSpec { + registry_branches: vec![], + paths: vec!["C:\\Apps\\Sample".to_string()], + }, + support_contact: None, + } +} + +pub(crate) fn write_embedded_payload(exe: &Path, payload: &[u8]) { + let mut bytes = payload.to_vec(); + bytes.extend_from_slice(&(payload.len() as u64).to_le_bytes()); + bytes.extend_from_slice(EMBEDDED_MAGIC); + fs::write(exe, bytes).unwrap(); +} + +pub(crate) fn quiet_logger() -> Logger { + Logger { + json: false, + quiet: true, + } +} diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 456aa0c..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,801 +0,0 @@ -use crate::{AppError, Logger, win}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::collections::VecDeque; -use std::fs::{self, OpenOptions}; -use std::io::{BufRead, BufReader, Read, Write}; -use std::os::windows::process::CommandExt; -use std::path::{Component, Path, PathBuf}; -use std::process::{self, Child, Command}; -use std::thread; -use std::time::{Duration, Instant}; - -const CREATE_NO_WINDOW: u32 = 0x0800_0000; -const UI_EXE_NAME: &str = "Covenant.Setup.Ui.exe"; -// Must match the bundle format written by write_ui_bundle in build.rs. -const UI_BUNDLE_MAGIC: &[u8] = b"COVENANT_SETUP_UI_BUNDLE_V1\n"; - -#[cfg(covenant_setup_embedded_ui)] -fn embedded_ui_bundle_bytes() -> Option<&'static [u8]> { - Some(include_bytes!(env!("COVENANT_SETUP_UI_BUNDLE"))) -} - -#[cfg(not(covenant_setup_embedded_ui))] -fn embedded_ui_bundle_bytes() -> Option<&'static [u8]> { - None -} - -pub fn is_available() -> bool { - embedded_ui_bundle_bytes().is_some() || sidecar_ui_exe().is_some() -} - -/// Trait abstraction over the live GUI progress IPC channel so install / -/// uninstall code can be unit-tested with a recording mock instead of -/// spawning the real C# UI. -pub trait ProgressSink: Send { - fn advance( - &mut self, - current_step: usize, - total_steps: usize, - message: &str, - ) -> Result<(), AppError>; - fn log(&mut self, message: &str) -> Result<(), AppError>; - fn finish(&mut self, message: &str, wait_for_close: bool) -> Result<(), AppError>; - fn fail( - &mut self, - app_name: &str, - operation: &str, - message: &str, - error: &str, - errata: Value, - wait_for_close: bool, - ) -> Result<(), AppError>; - fn cancel_requested(&mut self) -> bool { - false - } - fn clear_cancel_request(&mut self) {} -} - -impl ProgressSink for GuiProgress { - fn advance( - &mut self, - current_step: usize, - total_steps: usize, - message: &str, - ) -> Result<(), AppError> { - GuiProgress::advance(self, current_step, total_steps, message) - } - - fn log(&mut self, message: &str) -> Result<(), AppError> { - GuiProgress::log(self, message) - } - - fn finish(&mut self, message: &str, wait_for_close: bool) -> Result<(), AppError> { - GuiProgress::finish(self, message, wait_for_close) - } - - fn fail( - &mut self, - app_name: &str, - operation: &str, - message: &str, - error: &str, - errata: Value, - wait_for_close: bool, - ) -> Result<(), AppError> { - GuiProgress::fail( - self, - app_name, - operation, - message, - error, - errata, - wait_for_close, - ) - } - - fn cancel_requested(&mut self) -> bool { - if !self.cancel_seen { - self.cancel_seen = self.session.poll_cancel_request(&self.logger); - } - self.cancel_seen - } - - fn clear_cancel_request(&mut self) { - self.cancel_seen = false; - } -} - -pub struct GuiProgress { - session: CSharpUiSession, - logger: Logger, - cancel_seen: bool, -} - -impl GuiProgress { - pub fn start( - title: &str, - app_name: Option<&str>, - install_root: Option<&str>, - branding_image: Option<&str>, - show_welcome: bool, - total_steps: usize, - logger: &Logger, - ) -> Result { - let mut session = CSharpUiSession::start()?; - - session.send(&json!({ - "type": "init", - "title": title, - "app_name": app_name, - "install_dir": install_root, - "branding_image": branding_image, - "total_steps": total_steps.max(1), - "show_welcome": show_welcome, - }))?; - - #[derive(Deserialize)] - struct WelcomeResponse { - #[serde(rename = "type")] - message_type: String, - result: Option, - } - - // The explicit show_welcome flag is the single source of truth for - // the handshake: the UI shows the welcome page and replies exactly - // when the init message carries it, so the two sides cannot disagree - // about whether a welcome_response is owed. - if show_welcome { - let response: WelcomeResponse = session.read()?; - if response.message_type != "welcome_response" - || response.result.as_deref() != Some("install") - { - return Err(AppError::CancelledByUser); - } - } - - Ok(Self { - session, - logger: logger.clone(), - cancel_seen: false, - }) - } - - pub fn advance( - &mut self, - current_step: usize, - total_steps: usize, - message: &str, - ) -> Result<(), AppError> { - self.session.send(&json!({ - "type": "progress", - "current_step": current_step, - "total_steps": total_steps.max(1), - "message": message, - })) - } - - pub fn log(&mut self, message: &str) -> Result<(), AppError> { - self.session.send(&json!({ - "type": "log", - "message": message, - })) - } - - pub fn finish(&mut self, message: &str, wait_for_close: bool) -> Result<(), AppError> { - self.session.send(&json!({ - "type": "finish", - "message": message, - }))?; - if wait_for_close { - self.session.wait_for_exit()?; - } - Ok(()) - } - - pub fn fail( - &mut self, - app_name: &str, - operation: &str, - message: &str, - error: &str, - errata: Value, - wait_for_close: bool, - ) -> Result<(), AppError> { - self.session.send(&json!({ - "type": "fail", - "app_name": app_name, - "operation": operation, - "message": message, - "error": error, - "errata": errata, - }))?; - if wait_for_close { - self.session.wait_for_exit()?; - } - Ok(()) - } -} - -pub fn report_error(message: &str) -> Result<(), AppError> { - crate::trace_event("ui_prompt_report_error", json!({"message": message})); - let _ = prompt( - "covenant-setup", - message, - PromptButtons::Ok, - PromptIcon::Error, - )?; - Ok(()) -} - -pub fn report_uninstall_success(app_name: &str) -> Result<(), AppError> { - crate::trace_event( - "ui_prompt_report_uninstall_success", - json!({"app_name": app_name}), - ); - let _ = prompt( - "covenant-setup", - &format!("{app_name} uninstalled successfully!"), - PromptButtons::Ok, - PromptIcon::Information, - )?; - Ok(()) -} - -pub fn prompt_uninstall_reboot(app_name: &str) -> Result { - crate::trace_event("ui_prompt_uninstall_reboot", json!({"app_name": app_name})); - let result = prompt( - "covenant-setup", - &format!( - "{app_name} uninstalled successfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now." - ), - PromptButtons::YesNo, - PromptIcon::Information, - )?; - Ok(matches!(result, PromptResult::Yes)) -} - -fn prompt( - title: &str, - message: &str, - buttons: PromptButtons, - icon: PromptIcon, -) -> Result { - let mut session = CSharpUiSession::start()?; - let id = format!("prompt-{}", unique_suffix()); - session.send(&json!({ - "type": "prompt", - "id": id, - "title": title, - "message": message, - "buttons": buttons.as_str(), - "icon": icon.as_str(), - }))?; - let response: PromptResponse = session.read()?; - if response.message_type != "prompt_response" || response.id.as_deref() != Some(&id) { - return Err(AppError::Message("Unexpected UI prompt response".into())); - } - let result = PromptResult::from_str(response.result.as_deref().unwrap_or("none"))?; - crate::trace_event( - "ui_prompt_response", - json!({"id": id, "result": response.result}), - ); - Ok(result) -} - -enum PromptButtons { - Ok, - YesNo, -} - -impl PromptButtons { - fn as_str(&self) -> &'static str { - match self { - Self::Ok => "ok", - Self::YesNo => "yes_no", - } - } -} - -enum PromptIcon { - Information, - Error, -} - -impl PromptIcon { - fn as_str(&self) -> &'static str { - match self { - Self::Information => "information", - Self::Error => "error", - } - } -} - -enum PromptResult { - Ok, - Cancel, - Yes, - No, - None, -} - -impl PromptResult { - fn from_str(value: &str) -> Result { - match value { - "ok" => Ok(Self::Ok), - "cancel" => Ok(Self::Cancel), - "yes" => Ok(Self::Yes), - "no" => Ok(Self::No), - "none" => Ok(Self::None), - other => Err(AppError::Message(format!( - "Unknown UI prompt response: {other}" - ))), - } - } -} - -struct CSharpUiSession { - child: Child, - reader: BufReader, - writer: fs::File, - /// Raw bytes of a partial line pulled in by a non-blocking drain. - pending_input: Vec, - /// Complete lines drained while polling that no consumer has read yet. - queued_lines: VecDeque, - cleanup_root: Option, - closed: bool, - child_exited: bool, -} - -impl CSharpUiSession { - fn start() -> Result { - let pipe_name = format!("covenant-setup-ui-{}-{}", process::id(), unique_suffix()); - crate::trace_event("ui_start", json!({"pipe_name": pipe_name})); - let PreparedUiExe { - path: exe_path, - cleanup_root, - } = prepare_ui_exe()?; - crate::trace_event("ui_extracted", json!({"exe_path": &exe_path})); - let mut child = Command::new(&exe_path) - .creation_flags(CREATE_NO_WINDOW) - .arg("--pipe") - .arg(&pipe_name) - .spawn()?; - crate::trace_event( - "ui_spawned", - json!({"pid": child.id(), "exe_path": &exe_path}), - ); - let pipe_path = format!(r"\\.\pipe\{pipe_name}"); - let pipe = connect_pipe(&pipe_path, &mut child)?; - crate::trace_event("ui_pipe_connected", json!({"pipe_path": pipe_path})); - let writer = pipe.try_clone()?; - Ok(Self { - child, - reader: BufReader::new(pipe), - writer, - pending_input: Vec::new(), - queued_lines: VecDeque::new(), - cleanup_root, - closed: false, - child_exited: false, - }) - } - - fn send(&mut self, value: &T) -> Result<(), AppError> { - let value = serde_json::to_value(value)?; - crate::trace_event("ui_pipe_send", message_summary(&value)); - let bytes = serde_json::to_vec(&value)?; - self.writer.write_all(&bytes)?; - self.writer.write_all(b"\n")?; - self.writer.flush()?; - Ok(()) - } - - fn read Deserialize<'de>>(&mut self) -> Result { - let line = match self.queued_lines.pop_front() { - Some(line) => line, - None => { - // Resume any partial line a non-blocking drain left behind - // before falling back to a blocking read. - let mut line = String::from_utf8_lossy(&self.pending_input).into_owned(); - self.pending_input.clear(); - let bytes = self.reader.read_line(&mut line)?; - if bytes == 0 { - return Err(AppError::Message("UI pipe closed before response".into())); - } - line - } - }; - let value: Value = serde_json::from_str(&line)?; - crate::trace_event("ui_pipe_receive", message_summary(&value)); - Ok(serde_json::from_value(value)?) - } - - fn wait_for_exit(&mut self) -> Result<(), AppError> { - self.closed = true; - let status = self.child.wait()?; - self.child_exited = true; - crate::trace_event( - "ui_failure_window_closed", - json!({"pid": self.child.id(), "status": status.code()}), - ); - Ok(()) - } - - /// Pulls every byte currently available on the pipe without blocking and - /// queues complete lines for `read` or `poll_cancel_request` to consume. - /// Bytes are only pulled after PeekNamedPipe says they are available, - /// because a read left pending on this synchronous pipe handle would - /// serialize against (and stall) the engine's progress writes on the - /// same handle. - fn drain_available_input(&mut self, logger: &Logger) { - loop { - let buffered = self.reader.buffer().len(); - if buffered > 0 { - // Leftover bytes a previous blocking read pulled in. - let chunk = self.reader.buffer().to_vec(); - self.reader.consume(buffered); - self.pending_input.extend_from_slice(&chunk); - } else { - let available = win::peek_named_pipe_available(self.reader.get_ref(), logger) - .unwrap_or(0) as usize; - if available == 0 { - break; - } - let mut chunk = vec![0u8; available]; - match self.reader.get_mut().read(&mut chunk) { - Ok(count) if count > 0 => self.pending_input.extend_from_slice(&chunk[..count]), - _ => break, - } - } - - while let Some(newline) = self.pending_input.iter().position(|byte| *byte == b'\n') { - let line: Vec = self.pending_input.drain(..=newline).collect(); - self.queued_lines - .push_back(String::from_utf8_lossy(&line).into_owned()); - } - } - } - - /// Reports whether the UI sent a `cancel_request`. Only cancel lines are - /// consumed; every other UI->engine line stays queued for the next - /// blocking `read` instead of being dropped. - fn poll_cancel_request(&mut self, logger: &Logger) -> bool { - self.drain_available_input(logger); - let mut saw_cancel = false; - self.queued_lines.retain(|line| { - let is_cancel = serde_json::from_str::(line).is_ok_and(|value| { - value.get("type").and_then(Value::as_str) == Some("cancel_request") - }); - if is_cancel { - crate::trace_event("ui_cancel_request", json!({})); - saw_cancel = true; - } - !is_cancel - }); - saw_cancel - } -} - -impl Drop for CSharpUiSession { - fn drop(&mut self) { - if self.child_exited { - self.cleanup_ui_files(); - return; - } - if !self.closed { - crate::trace_event("ui_close_send", json!({"pid": self.child.id()})); - let _ = self.send(&json!({"type": "close"})); - self.closed = true; - } - for _ in 0..20 { - if self.child.try_wait().ok().flatten().is_some() { - crate::trace_event("ui_exited", json!({"pid": self.child.id()})); - self.cleanup_ui_files(); - return; - } - thread::sleep(Duration::from_millis(50)); - } - let _ = self.child.kill(); - let _ = self.child.wait(); - crate::trace_event("ui_killed", json!({"pid": self.child.id()})); - self.cleanup_ui_files(); - } -} - -impl CSharpUiSession { - fn cleanup_ui_files(&self) { - if let Some(root) = &self.cleanup_root { - let _ = fs::remove_dir_all(root); - } - } -} - -#[derive(Deserialize)] -struct PromptResponse { - #[serde(rename = "type")] - message_type: String, - id: Option, - result: Option, -} - -fn connect_pipe(pipe_path: &str, child: &mut Child) -> Result { - let deadline = Instant::now() + Duration::from_secs(15); - crate::trace_event("ui_pipe_connect_wait", json!({"pipe_path": pipe_path})); - loop { - match OpenOptions::new().read(true).write(true).open(pipe_path) { - Ok(file) => return Ok(file), - Err(err) => { - if let Some(status) = child.try_wait()? { - crate::trace_event( - "ui_pipe_connect_child_exited", - json!({"pipe_path": pipe_path, "status": status.to_string()}), - ); - return Err(AppError::Message(format!( - "C# UI exited before pipe connection: {status}" - ))); - } - if Instant::now() >= deadline { - crate::trace_event( - "ui_pipe_connect_timeout", - json!({"pipe_path": pipe_path, "error": err.to_string()}), - ); - return Err(AppError::Message(format!( - "Timed out connecting to C# UI pipe {pipe_path}: {err}" - ))); - } - thread::sleep(Duration::from_millis(50)); - } - } - } -} - -struct PreparedUiExe { - path: PathBuf, - cleanup_root: Option, -} - -fn prepare_ui_exe() -> Result { - if let Some(bytes) = embedded_ui_bundle_bytes() { - return extract_ui_bundle(bytes); - } - if let Some(path) = sidecar_ui_exe() { - return Ok(PreparedUiExe { - path, - cleanup_root: None, - }); - } - Err(AppError::Message(format!( - "C# UI helper is not bundled and no {UI_EXE_NAME} was found next to the installer" - ))) -} - -fn extract_ui_bundle(bytes: &[u8]) -> Result { - if !bytes.starts_with(UI_BUNDLE_MAGIC) { - return Err(AppError::Message("Embedded C# UI bundle is invalid".into())); - } - - let root = std::env::temp_dir().join(format!( - "covenant-setup-ui-{}-{}", - process::id(), - unique_suffix() - )); - fs::create_dir_all(&root)?; - let mut cursor = UI_BUNDLE_MAGIC.len(); - - loop { - let path_len = read_bundle_u32(bytes, &mut cursor)? as usize; - let data_len = usize::try_from(read_bundle_u64(bytes, &mut cursor)?) - .map_err(|_| AppError::Message("Embedded C# UI file is too large".into()))?; - if path_len == 0 { - if data_len != 0 { - return Err(AppError::Message( - "Embedded C# UI bundle has an invalid terminator".into(), - )); - } - break; - } - - let relative_path = std::str::from_utf8(take_bundle_bytes(bytes, &mut cursor, path_len)?) - .map_err(|_| { - AppError::Message("Embedded C# UI bundle path is not valid UTF-8".into()) - })?; - let data = take_bundle_bytes(bytes, &mut cursor, data_len)?; - - let output_path = bundle_output_path(&root, relative_path)?; - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(output_path, data)?; - } - - let path = root.join(UI_EXE_NAME); - if !path.is_file() { - let _ = fs::remove_dir_all(&root); - return Err(AppError::Message(format!( - "Embedded C# UI bundle did not contain {UI_EXE_NAME}" - ))); - } - - Ok(PreparedUiExe { - path, - cleanup_root: Some(root), - }) -} - -fn sidecar_ui_exe() -> Option { - let path = std::env::current_exe().ok()?.parent()?.join(UI_EXE_NAME); - path.is_file().then_some(path) -} - -fn read_bundle_u32(bytes: &[u8], cursor: &mut usize) -> Result { - Ok(u32::from_le_bytes( - take_bundle_bytes(bytes, cursor, 4)?.try_into().unwrap(), - )) -} - -fn read_bundle_u64(bytes: &[u8], cursor: &mut usize) -> Result { - Ok(u64::from_le_bytes( - take_bundle_bytes(bytes, cursor, 8)?.try_into().unwrap(), - )) -} - -/// Advances `cursor` past the next `len` bytes and returns them, or errors if -/// the bundle is truncated. -fn take_bundle_bytes<'a>( - bytes: &'a [u8], - cursor: &mut usize, - len: usize, -) -> Result<&'a [u8], AppError> { - let end = cursor - .checked_add(len) - .filter(|end| *end <= bytes.len()) - .ok_or_else(|| AppError::Message("Embedded C# UI bundle ended unexpectedly".into()))?; - let slice = &bytes[*cursor..end]; - *cursor = end; - Ok(slice) -} - -fn bundle_output_path(root: &Path, relative_path: &str) -> Result { - let relative_path = Path::new(relative_path); - if relative_path.is_absolute() { - return Err(AppError::Message( - "Embedded C# UI bundle contains an absolute path".into(), - )); - } - - let mut output_path = root.to_path_buf(); - for component in relative_path.components() { - match component { - Component::Normal(part) => output_path.push(part), - _ => { - return Err(AppError::Message( - "Embedded C# UI bundle contains an unsafe path".into(), - )); - } - } - } - Ok(output_path) -} - -fn message_summary(value: &Value) -> Value { - json!({ - "type": value.get("type").and_then(Value::as_str), - "id": value.get("id").and_then(Value::as_str), - "message": value.get("message").and_then(Value::as_str), - }) -} - -fn unique_suffix() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn prompt_button_and_icon_names_match_protocol() { - assert_eq!(PromptButtons::Ok.as_str(), "ok"); - assert_eq!(PromptButtons::YesNo.as_str(), "yes_no"); - assert_eq!(PromptIcon::Information.as_str(), "information"); - assert_eq!(PromptIcon::Error.as_str(), "error"); - } - - #[test] - fn prompt_result_parses_known_values_and_rejects_unknown_values() { - assert!(matches!( - PromptResult::from_str("ok").unwrap(), - PromptResult::Ok - )); - assert!(matches!( - PromptResult::from_str("cancel").unwrap(), - PromptResult::Cancel - )); - assert!(matches!( - PromptResult::from_str("yes").unwrap(), - PromptResult::Yes - )); - assert!(matches!( - PromptResult::from_str("no").unwrap(), - PromptResult::No - )); - assert!(matches!( - PromptResult::from_str("none").unwrap(), - PromptResult::None - )); - assert!(PromptResult::from_str("maybe").is_err()); - } - - #[test] - fn message_summary_extracts_only_safe_protocol_fields() { - let summary = message_summary(&json!({ - "type": "progress", - "id": "abc", - "message": "Working", - "errata": {"secret": true} - })); - - assert_eq!(summary["type"], "progress"); - assert_eq!(summary["id"], "abc"); - assert_eq!(summary["message"], "Working"); - assert!(summary.get("errata").is_none()); - } - - #[test] - fn extract_ui_bundle_writes_temp_application_folder_and_marks_it_for_cleanup() { - let bundle = make_ui_bundle(&[ - (UI_EXE_NAME, b"fake exe".as_slice()), - ("runtimes/win-x64/native/helper.dll", b"dll".as_slice()), - ]); - let prepared = extract_ui_bundle(&bundle).unwrap(); - - assert_eq!(fs::read(&prepared.path).unwrap(), b"fake exe"); - let cleanup_root = prepared.cleanup_root.clone().unwrap(); - assert!( - cleanup_root - .join("runtimes/win-x64/native/helper.dll") - .is_file() - ); - fs::remove_dir_all(cleanup_root).unwrap(); - } - - #[test] - fn prepare_ui_exe_returns_available_helper_or_clear_missing_error() { - match prepare_ui_exe() { - Ok(prepared) => { - assert!(prepared.path.is_file()); - if let Some(cleanup_root) = prepared.cleanup_root { - fs::remove_dir_all(cleanup_root).unwrap(); - } - } - Err(err) => { - assert!(err.to_string().contains("C# UI helper is not bundled")); - assert!(err.to_string().contains(UI_EXE_NAME)); - } - } - } - - #[test] - fn availability_and_suffix_helpers_are_callable_without_side_effect_requirements() { - let _ = is_available(); - assert!(unique_suffix() > 0); - } - - fn make_ui_bundle(files: &[(&str, &[u8])]) -> Vec { - let mut bundle = Vec::new(); - bundle.extend_from_slice(UI_BUNDLE_MAGIC); - for (relative_path, data) in files { - bundle.extend_from_slice(&(relative_path.len() as u32).to_le_bytes()); - bundle.extend_from_slice(&(data.len() as u64).to_le_bytes()); - bundle.extend_from_slice(relative_path.as_bytes()); - bundle.extend_from_slice(data); - } - bundle.extend_from_slice(&0u32.to_le_bytes()); - bundle.extend_from_slice(&0u64.to_le_bytes()); - bundle - } -} diff --git a/src/ui/bundle.rs b/src/ui/bundle.rs new file mode 100644 index 0000000..2045641 --- /dev/null +++ b/src/ui/bundle.rs @@ -0,0 +1,148 @@ +use super::*; + +pub(crate) const UI_EXE_NAME: &str = "Covenant.Setup.Ui.exe"; +// Must match the bundle format written by write_ui_bundle in build.rs. +pub(crate) const UI_BUNDLE_MAGIC: &[u8] = b"COVENANT_SETUP_UI_BUNDLE_V1\n"; +#[cfg(covenant_setup_embedded_ui)] +pub(crate) fn embedded_ui_bundle_bytes() -> Option<&'static [u8]> { + Some(include_bytes!(env!("COVENANT_SETUP_UI_BUNDLE"))) +} + +#[cfg(not(covenant_setup_embedded_ui))] +pub(crate) fn embedded_ui_bundle_bytes() -> Option<&'static [u8]> { + None +} + +pub fn is_available() -> bool { + embedded_ui_bundle_bytes().is_some() || sidecar_ui_exe().is_some() +} + +/// Trait abstraction over the live GUI progress IPC channel so install / +/// uninstall code can be unit-tested with a recording mock instead of +pub(crate) struct PreparedUiExe { + pub(crate) path: PathBuf, + pub(crate) cleanup_root: Option, +} + +pub(crate) fn prepare_ui_exe() -> Result { + if let Some(bytes) = embedded_ui_bundle_bytes() { + return extract_ui_bundle(bytes); + } + if let Some(path) = sidecar_ui_exe() { + return Ok(PreparedUiExe { + path, + cleanup_root: None, + }); + } + Err(AppError::Message(format!( + "C# UI helper is not bundled and no {UI_EXE_NAME} was found next to the installer" + ))) +} + +pub(crate) fn extract_ui_bundle(bytes: &[u8]) -> Result { + if !bytes.starts_with(UI_BUNDLE_MAGIC) { + return Err(AppError::Message("Embedded C# UI bundle is invalid".into())); + } + + let root = std::env::temp_dir().join(format!( + "covenant-setup-ui-{}-{}", + process::id(), + unique_suffix() + )); + fs::create_dir_all(&root)?; + let mut cursor = UI_BUNDLE_MAGIC.len(); + + loop { + let path_len = read_bundle_u32(bytes, &mut cursor)? as usize; + let data_len = usize::try_from(read_bundle_u64(bytes, &mut cursor)?) + .map_err(|_| AppError::Message("Embedded C# UI file is too large".into()))?; + if path_len == 0 { + if data_len != 0 { + return Err(AppError::Message( + "Embedded C# UI bundle has an invalid terminator".into(), + )); + } + break; + } + + let relative_path = std::str::from_utf8(take_bundle_bytes(bytes, &mut cursor, path_len)?) + .map_err(|_| { + AppError::Message("Embedded C# UI bundle path is not valid UTF-8".into()) + })?; + let data = take_bundle_bytes(bytes, &mut cursor, data_len)?; + + let output_path = bundle_output_path(&root, relative_path)?; + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(output_path, data)?; + } + + let path = root.join(UI_EXE_NAME); + if !path.is_file() { + let _ = fs::remove_dir_all(&root); + return Err(AppError::Message(format!( + "Embedded C# UI bundle did not contain {UI_EXE_NAME}" + ))); + } + + Ok(PreparedUiExe { + path, + cleanup_root: Some(root), + }) +} + +pub(crate) fn sidecar_ui_exe() -> Option { + let path = std::env::current_exe().ok()?.parent()?.join(UI_EXE_NAME); + path.is_file().then_some(path) +} + +pub(crate) fn read_bundle_u32(bytes: &[u8], cursor: &mut usize) -> Result { + Ok(u32::from_le_bytes( + take_bundle_bytes(bytes, cursor, 4)?.try_into().unwrap(), + )) +} + +pub(crate) fn read_bundle_u64(bytes: &[u8], cursor: &mut usize) -> Result { + Ok(u64::from_le_bytes( + take_bundle_bytes(bytes, cursor, 8)?.try_into().unwrap(), + )) +} + +/// Advances `cursor` past the next `len` bytes and returns them, or errors if +/// the bundle is truncated. +pub(crate) fn take_bundle_bytes<'a>( + bytes: &'a [u8], + cursor: &mut usize, + len: usize, +) -> Result<&'a [u8], AppError> { + let end = cursor + .checked_add(len) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| AppError::Message("Embedded C# UI bundle ended unexpectedly".into()))?; + let slice = &bytes[*cursor..end]; + *cursor = end; + Ok(slice) +} + +pub(crate) fn bundle_output_path(root: &Path, relative_path: &str) -> Result { + let relative_path = Path::new(relative_path); + if relative_path.is_absolute() { + return Err(AppError::Message( + "Embedded C# UI bundle contains an absolute path".into(), + )); + } + + let mut output_path = root.to_path_buf(); + for component in relative_path.components() { + match component { + Component::Normal(part) => output_path.push(part), + _ => { + return Err(AppError::Message( + "Embedded C# UI bundle contains an unsafe path".into(), + )); + } + } + } + Ok(output_path) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..eb32d5a --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,127 @@ +use crate::{AppError, Logger, win}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::VecDeque; +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::windows::process::CommandExt; +use std::path::{Component, Path, PathBuf}; +use std::process::{self, Child, Command}; +use std::thread; +use std::time::{Duration, Instant}; + +mod bundle; +mod progress; +mod prompts; +mod session; + +pub(crate) use bundle::*; +pub(crate) use progress::*; +pub(crate) use prompts::*; +pub(crate) use session::*; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_button_and_icon_names_match_protocol() { + assert_eq!(PromptButtons::Ok.as_str(), "ok"); + assert_eq!(PromptButtons::YesNo.as_str(), "yes_no"); + assert_eq!(PromptIcon::Information.as_str(), "information"); + assert_eq!(PromptIcon::Error.as_str(), "error"); + } + + #[test] + fn prompt_result_parses_known_values_and_rejects_unknown_values() { + assert!(matches!( + PromptResult::from_str("ok").unwrap(), + PromptResult::Ok + )); + assert!(matches!( + PromptResult::from_str("cancel").unwrap(), + PromptResult::Cancel + )); + assert!(matches!( + PromptResult::from_str("yes").unwrap(), + PromptResult::Yes + )); + assert!(matches!( + PromptResult::from_str("no").unwrap(), + PromptResult::No + )); + assert!(matches!( + PromptResult::from_str("none").unwrap(), + PromptResult::None + )); + assert!(PromptResult::from_str("maybe").is_err()); + } + + #[test] + fn message_summary_extracts_only_safe_protocol_fields() { + let summary = message_summary(&json!({ + "type": "progress", + "id": "abc", + "message": "Working", + "errata": {"secret": true} + })); + + assert_eq!(summary["type"], "progress"); + assert_eq!(summary["id"], "abc"); + assert_eq!(summary["message"], "Working"); + assert!(summary.get("errata").is_none()); + } + + #[test] + fn extract_ui_bundle_writes_temp_application_folder_and_marks_it_for_cleanup() { + let bundle = make_ui_bundle(&[ + (UI_EXE_NAME, b"fake exe".as_slice()), + ("runtimes/win-x64/native/helper.dll", b"dll".as_slice()), + ]); + let prepared = extract_ui_bundle(&bundle).unwrap(); + + assert_eq!(fs::read(&prepared.path).unwrap(), b"fake exe"); + let cleanup_root = prepared.cleanup_root.clone().unwrap(); + assert!( + cleanup_root + .join("runtimes/win-x64/native/helper.dll") + .is_file() + ); + fs::remove_dir_all(cleanup_root).unwrap(); + } + + #[test] + fn prepare_ui_exe_returns_available_helper_or_clear_missing_error() { + match prepare_ui_exe() { + Ok(prepared) => { + assert!(prepared.path.is_file()); + if let Some(cleanup_root) = prepared.cleanup_root { + fs::remove_dir_all(cleanup_root).unwrap(); + } + } + Err(err) => { + assert!(err.to_string().contains("C# UI helper is not bundled")); + assert!(err.to_string().contains(UI_EXE_NAME)); + } + } + } + + #[test] + fn availability_and_suffix_helpers_are_callable_without_side_effect_requirements() { + let _ = is_available(); + assert!(unique_suffix() > 0); + } + + fn make_ui_bundle(files: &[(&str, &[u8])]) -> Vec { + let mut bundle = Vec::new(); + bundle.extend_from_slice(UI_BUNDLE_MAGIC); + for (relative_path, data) in files { + bundle.extend_from_slice(&(relative_path.len() as u32).to_le_bytes()); + bundle.extend_from_slice(&(data.len() as u64).to_le_bytes()); + bundle.extend_from_slice(relative_path.as_bytes()); + bundle.extend_from_slice(data); + } + bundle.extend_from_slice(&0u32.to_le_bytes()); + bundle.extend_from_slice(&0u64.to_le_bytes()); + bundle + } +} diff --git a/src/ui/progress.rs b/src/ui/progress.rs new file mode 100644 index 0000000..e678c6a --- /dev/null +++ b/src/ui/progress.rs @@ -0,0 +1,186 @@ +use super::*; + +pub trait ProgressSink: Send { + fn advance( + &mut self, + current_step: usize, + total_steps: usize, + message: &str, + ) -> Result<(), AppError>; + fn log(&mut self, message: &str) -> Result<(), AppError>; + fn finish(&mut self, message: &str, wait_for_close: bool) -> Result<(), AppError>; + fn fail( + &mut self, + app_name: &str, + operation: &str, + message: &str, + error: &str, + errata: Value, + wait_for_close: bool, + ) -> Result<(), AppError>; + fn cancel_requested(&mut self) -> bool { + false + } + fn clear_cancel_request(&mut self) {} +} + +impl ProgressSink for GuiProgress { + fn advance( + &mut self, + current_step: usize, + total_steps: usize, + message: &str, + ) -> Result<(), AppError> { + GuiProgress::advance(self, current_step, total_steps, message) + } + + fn log(&mut self, message: &str) -> Result<(), AppError> { + GuiProgress::log(self, message) + } + + fn finish(&mut self, message: &str, wait_for_close: bool) -> Result<(), AppError> { + GuiProgress::finish(self, message, wait_for_close) + } + + fn fail( + &mut self, + app_name: &str, + operation: &str, + message: &str, + error: &str, + errata: Value, + wait_for_close: bool, + ) -> Result<(), AppError> { + GuiProgress::fail( + self, + app_name, + operation, + message, + error, + errata, + wait_for_close, + ) + } + + fn cancel_requested(&mut self) -> bool { + if !self.cancel_seen { + self.cancel_seen = self.session.poll_cancel_request(&self.logger); + } + self.cancel_seen + } + + fn clear_cancel_request(&mut self) { + self.cancel_seen = false; + } +} + +pub struct GuiProgress { + session: CSharpUiSession, + logger: Logger, + cancel_seen: bool, +} + +impl GuiProgress { + pub fn start( + title: &str, + app_name: Option<&str>, + install_root: Option<&str>, + branding_image: Option<&str>, + show_welcome: bool, + total_steps: usize, + logger: &Logger, + ) -> Result { + let mut session = CSharpUiSession::start()?; + + session.send(&json!({ + "type": "init", + "title": title, + "app_name": app_name, + "install_dir": install_root, + "branding_image": branding_image, + "total_steps": total_steps.max(1), + "show_welcome": show_welcome, + }))?; + + #[derive(serde::Deserialize)] + struct WelcomeResponse { + #[serde(rename = "type")] + message_type: String, + result: Option, + } + + // The explicit show_welcome flag is the single source of truth for + // the handshake: the UI shows the welcome page and replies exactly + // when the init message carries it, so the two sides cannot disagree + // about whether a welcome_response is owed. + if show_welcome { + let response: WelcomeResponse = session.read()?; + if response.message_type != "welcome_response" + || response.result.as_deref() != Some("install") + { + return Err(AppError::CancelledByUser); + } + } + + Ok(Self { + session, + logger: logger.clone(), + cancel_seen: false, + }) + } + + pub fn advance( + &mut self, + current_step: usize, + total_steps: usize, + message: &str, + ) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "progress", + "current_step": current_step, + "total_steps": total_steps.max(1), + "message": message, + })) + } + + pub fn log(&mut self, message: &str) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "log", + "message": message, + })) + } + + pub fn finish(&mut self, message: &str, wait_for_close: bool) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "finish", + "message": message, + }))?; + if wait_for_close { + self.session.wait_for_exit()?; + } + Ok(()) + } + + pub fn fail( + &mut self, + app_name: &str, + operation: &str, + message: &str, + error: &str, + errata: Value, + wait_for_close: bool, + ) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "fail", + "app_name": app_name, + "operation": operation, + "message": message, + "error": error, + "errata": errata, + }))?; + if wait_for_close { + self.session.wait_for_exit()?; + } + Ok(()) + } +} diff --git a/src/ui/prompts.rs b/src/ui/prompts.rs new file mode 100644 index 0000000..c4e2bf3 --- /dev/null +++ b/src/ui/prompts.rs @@ -0,0 +1,125 @@ +use super::*; + +pub fn report_error(message: &str) -> Result<(), AppError> { + crate::trace_event("ui_prompt_report_error", json!({"message": message})); + let _ = prompt( + "covenant-setup", + message, + PromptButtons::Ok, + PromptIcon::Error, + )?; + Ok(()) +} + +pub fn report_uninstall_success(app_name: &str) -> Result<(), AppError> { + crate::trace_event( + "ui_prompt_report_uninstall_success", + json!({"app_name": app_name}), + ); + let _ = prompt( + "covenant-setup", + &format!("{app_name} uninstalled successfully!"), + PromptButtons::Ok, + PromptIcon::Information, + )?; + Ok(()) +} + +pub fn prompt_uninstall_reboot(app_name: &str) -> Result { + crate::trace_event("ui_prompt_uninstall_reboot", json!({"app_name": app_name})); + let result = prompt( + "covenant-setup", + &format!( + "{app_name} uninstalled successfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now." + ), + PromptButtons::YesNo, + PromptIcon::Information, + )?; + Ok(matches!(result, PromptResult::Yes)) +} + +pub(crate) fn prompt( + title: &str, + message: &str, + buttons: PromptButtons, + icon: PromptIcon, +) -> Result { + let mut session = CSharpUiSession::start()?; + let id = format!("prompt-{}", unique_suffix()); + session.send(&json!({ + "type": "prompt", + "id": id, + "title": title, + "message": message, + "buttons": buttons.as_str(), + "icon": icon.as_str(), + }))?; + let response: PromptResponse = session.read()?; + if response.message_type != "prompt_response" || response.id.as_deref() != Some(&id) { + return Err(AppError::Message("Unexpected UI prompt response".into())); + } + let result = PromptResult::from_str(response.result.as_deref().unwrap_or("none"))?; + crate::trace_event( + "ui_prompt_response", + json!({"id": id, "result": response.result}), + ); + Ok(result) +} + +pub(crate) enum PromptButtons { + Ok, + YesNo, +} + +impl PromptButtons { + pub(crate) fn as_str(&self) -> &'static str { + match self { + Self::Ok => "ok", + Self::YesNo => "yes_no", + } + } +} + +pub(crate) enum PromptIcon { + Information, + Error, +} + +impl PromptIcon { + pub(crate) fn as_str(&self) -> &'static str { + match self { + Self::Information => "information", + Self::Error => "error", + } + } +} + +pub(crate) enum PromptResult { + Ok, + Cancel, + Yes, + No, + None, +} + +impl PromptResult { + pub(crate) fn from_str(value: &str) -> Result { + match value { + "ok" => Ok(Self::Ok), + "cancel" => Ok(Self::Cancel), + "yes" => Ok(Self::Yes), + "no" => Ok(Self::No), + "none" => Ok(Self::None), + other => Err(AppError::Message(format!( + "Unknown UI prompt response: {other}" + ))), + } + } +} +#[derive(serde::Deserialize)] +pub(crate) struct PromptResponse { + #[serde(rename = "type")] + message_type: String, + id: Option, + result: Option, +} diff --git a/src/ui/session.rs b/src/ui/session.rs new file mode 100644 index 0000000..a861f9f --- /dev/null +++ b/src/ui/session.rs @@ -0,0 +1,229 @@ +use super::*; + +pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000; +pub(crate) struct CSharpUiSession { + child: Child, + reader: BufReader, + writer: fs::File, + /// Raw bytes of a partial line pulled in by a non-blocking drain. + pending_input: Vec, + /// Complete non-cancel lines drained while polling that no consumer has + /// read yet. + queued_lines: VecDeque, + /// Set when a `cancel_request` line was seen during a drain; consumed by + /// `poll_cancel_request`. + cancel_pending: bool, + cleanup_root: Option, + closed: bool, + child_exited: bool, +} + +impl CSharpUiSession { + pub(crate) fn start() -> Result { + let pipe_name = format!("covenant-setup-ui-{}-{}", process::id(), unique_suffix()); + crate::trace_event("ui_start", json!({"pipe_name": pipe_name})); + let PreparedUiExe { + path: exe_path, + cleanup_root, + } = prepare_ui_exe()?; + crate::trace_event("ui_extracted", json!({"exe_path": &exe_path})); + let mut child = Command::new(&exe_path) + .creation_flags(CREATE_NO_WINDOW) + .arg("--pipe") + .arg(&pipe_name) + .spawn()?; + crate::trace_event( + "ui_spawned", + json!({"pid": child.id(), "exe_path": &exe_path}), + ); + let pipe_path = format!(r"\\.\pipe\{pipe_name}"); + let pipe = connect_pipe(&pipe_path, &mut child)?; + crate::trace_event("ui_pipe_connected", json!({"pipe_path": pipe_path})); + let writer = pipe.try_clone()?; + Ok(Self { + child, + reader: BufReader::new(pipe), + writer, + pending_input: Vec::new(), + queued_lines: VecDeque::new(), + cancel_pending: false, + cleanup_root, + closed: false, + child_exited: false, + }) + } + + pub(crate) fn send(&mut self, value: &T) -> Result<(), AppError> { + let value = serde_json::to_value(value)?; + crate::trace_event("ui_pipe_send", message_summary(&value)); + let bytes = serde_json::to_vec(&value)?; + self.writer.write_all(&bytes)?; + self.writer.write_all(b"\n")?; + self.writer.flush()?; + Ok(()) + } + + pub(crate) fn read Deserialize<'de>>(&mut self) -> Result { + let line = match self.queued_lines.pop_front() { + Some(line) => line, + None => { + // Resume any partial line a non-blocking drain left behind + // before falling back to a blocking read. + let mut line = String::from_utf8_lossy(&self.pending_input).into_owned(); + self.pending_input.clear(); + let bytes = self.reader.read_line(&mut line)?; + if bytes == 0 { + return Err(AppError::Message("UI pipe closed before response".into())); + } + line + } + }; + let value: Value = serde_json::from_str(&line)?; + crate::trace_event("ui_pipe_receive", message_summary(&value)); + Ok(serde_json::from_value(value)?) + } + + pub(crate) fn wait_for_exit(&mut self) -> Result<(), AppError> { + self.closed = true; + let status = self.child.wait()?; + self.child_exited = true; + crate::trace_event( + "ui_failure_window_closed", + json!({"pid": self.child.id(), "status": status.code()}), + ); + Ok(()) + } + + /// Pulls every byte currently available on the pipe without blocking, + /// classifying each complete line exactly once: `cancel_request` lines set + /// `cancel_pending`, every other line is queued for the next blocking + /// `read`. Bytes are only pulled after PeekNamedPipe says they are + /// available, because a read left pending on this synchronous pipe handle + /// would serialize against (and stall) the engine's progress writes on the + /// same handle. + fn drain_available_input(&mut self, logger: &Logger) { + loop { + let buffered = self.reader.buffer().len(); + if buffered > 0 { + // Leftover bytes a previous blocking read pulled in. + let chunk = self.reader.buffer().to_vec(); + self.reader.consume(buffered); + self.pending_input.extend_from_slice(&chunk); + } else { + let available = win::peek_named_pipe_available(self.reader.get_ref(), logger) + .unwrap_or(0) as usize; + if available == 0 { + break; + } + let mut chunk = vec![0u8; available]; + match self.reader.get_mut().read(&mut chunk) { + Ok(count) if count > 0 => self.pending_input.extend_from_slice(&chunk[..count]), + _ => break, + } + } + + while let Some(newline) = self.pending_input.iter().position(|byte| *byte == b'\n') { + let line: Vec = self.pending_input.drain(..=newline).collect(); + let line = String::from_utf8_lossy(&line).into_owned(); + if is_cancel_request(&line) { + crate::trace_event("ui_cancel_request", json!({})); + self.cancel_pending = true; + } else { + self.queued_lines.push_back(line); + } + } + } + } + + /// Reports whether the UI sent a `cancel_request`. Only cancel lines are + /// consumed; every other UI->engine line stays queued for the next + /// blocking `read` instead of being dropped. + pub(crate) fn poll_cancel_request(&mut self, logger: &Logger) -> bool { + self.drain_available_input(logger); + std::mem::take(&mut self.cancel_pending) + } +} + +pub(crate) fn is_cancel_request(line: &str) -> bool { + serde_json::from_str::(line) + .is_ok_and(|value| value.get("type").and_then(Value::as_str) == Some("cancel_request")) +} + +impl Drop for CSharpUiSession { + fn drop(&mut self) { + if self.child_exited { + self.cleanup_ui_files(); + return; + } + if !self.closed { + crate::trace_event("ui_close_send", json!({"pid": self.child.id()})); + let _ = self.send(&json!({"type": "close"})); + self.closed = true; + } + for _ in 0..20 { + if self.child.try_wait().ok().flatten().is_some() { + crate::trace_event("ui_exited", json!({"pid": self.child.id()})); + self.cleanup_ui_files(); + return; + } + thread::sleep(Duration::from_millis(50)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + crate::trace_event("ui_killed", json!({"pid": self.child.id()})); + self.cleanup_ui_files(); + } +} + +impl CSharpUiSession { + fn cleanup_ui_files(&self) { + if let Some(root) = &self.cleanup_root { + let _ = fs::remove_dir_all(root); + } + } +} + +pub(crate) fn connect_pipe(pipe_path: &str, child: &mut Child) -> Result { + let deadline = Instant::now() + Duration::from_secs(15); + crate::trace_event("ui_pipe_connect_wait", json!({"pipe_path": pipe_path})); + loop { + match OpenOptions::new().read(true).write(true).open(pipe_path) { + Ok(file) => return Ok(file), + Err(err) => { + if let Some(status) = child.try_wait()? { + crate::trace_event( + "ui_pipe_connect_child_exited", + json!({"pipe_path": pipe_path, "status": status.to_string()}), + ); + return Err(AppError::Message(format!( + "C# UI exited before pipe connection: {status}" + ))); + } + if Instant::now() >= deadline { + crate::trace_event( + "ui_pipe_connect_timeout", + json!({"pipe_path": pipe_path, "error": err.to_string()}), + ); + return Err(AppError::Message(format!( + "Timed out connecting to C# UI pipe {pipe_path}: {err}" + ))); + } + thread::sleep(Duration::from_millis(50)); + } + } + } +} +pub(crate) fn message_summary(value: &Value) -> Value { + json!({ + "type": value.get("type").and_then(Value::as_str), + "id": value.get("id").and_then(Value::as_str), + "message": value.get("message").and_then(Value::as_str), + }) +} + +pub(crate) fn unique_suffix() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() +} diff --git a/src/uninstall.rs b/src/uninstall.rs new file mode 100644 index 0000000..bbf2c4f --- /dev/null +++ b/src/uninstall.rs @@ -0,0 +1,837 @@ +use crate::sys::Sys; +use crate::*; +use serde_json::json; +use std::fs; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub(crate) fn uninstall( + journal_path: &Path, + elevate: bool, + ui_mode: UiMode, + automation: bool, + sys: &dyn Sys, + gui_progress: &mut Option>, + logger: &Logger, + is_rollback: bool, +) -> Result<(), AppError> { + let journal: Journal = serde_json::from_str(&fs::read_to_string(journal_path)?)?; + trace_event( + if is_rollback { + "rollback_start" + } else { + "uninstall_start" + }, + json!({"journal": journal_path, "app_name": &journal.app_name}), + ); + let app_name = journal.app_name.clone(); + let action_verb = if is_rollback { + "Reverting" + } else { + "Uninstalling" + }; + let _progress = start_tui_progress(ui_mode, format!("{} {} ", action_verb, journal.app_name)); + let uninstall_total = total_uninstall_steps(&journal); + // A rollback reuses the live install progress session passed in by the + // caller; only a standalone uninstall starts its own. + if gui_progress.is_none() { + *gui_progress = start_gui_progress( + ui_mode, + sys, + &format!("{} {}", action_verb, journal.app_name), + if is_rollback { + Some(&journal.app_name) + } else { + None + }, + None, + None, + false, + uninstall_total, + logger, + )?; + } + let result = (|| -> Result<(), AppError> { + let effective_logger = if ui_mode == UiMode::Tui { + logger.quiet_clone() + } else { + logger.clone() + }; + let resolver = win::PathResolver::new(&effective_logger)?; + let requires_admin = journal_requires_admin(&journal, &resolver)?; + ensure_elevation_if_needed(requires_admin, elevate, sys, &effective_logger)?; + trace_event( + "uninstall_elevation_checked", + json!({"requires_admin": requires_admin, "elevate": elevate}), + ); + let current_exe = std::env::current_exe().ok(); + let mut deferred_self_delete: Option = None; + let mut deferred_uninstall_registry: Vec<(RegistryRoot, String)> = Vec::new(); + let mut progress_step = 0usize; + + for action in journal.actions.iter().rev() { + match action { + JournalAction::CreateDirectory { path } => { + advance_gui_progress_step( + gui_progress, + &mut progress_step, + uninstall_total, + &format!("Removing directory {}", path.display()), + )?; + if journal_path.starts_with(path) && journal_path.exists() { + let _ = fs::remove_file(journal_path); + } + win::remove_directory_if_exists(path, &effective_logger)? + } + JournalAction::CopyFile { destination, .. } => { + if current_exe + .as_ref() + .is_some_and(|exe| same_path(exe, destination)) + { + effective_logger.info("defer_self_delete", json!({"path":destination})); + deferred_self_delete = Some(destination.clone()); + } else { + advance_gui_progress_step( + gui_progress, + &mut progress_step, + uninstall_total, + &format!("Removing file {}", destination.display()), + )?; + sys.remove_file_with_fallback(destination, &effective_logger)? + } + } + JournalAction::WriteRegistry { root, subkey, .. } => { + if is_uninstall_registry_key(subkey) { + push_unique_registry_branch( + &mut deferred_uninstall_registry, + *root, + subkey.clone(), + ); + } else { + advance_gui_progress_step( + gui_progress, + &mut progress_step, + uninstall_total, + &format!("Removing registry branch {}", subkey), + )?; + sys.delete_registry_tree(*root, subkey, &effective_logger)? + } + } + JournalAction::CreateShortcut { path } => { + advance_gui_progress_step( + gui_progress, + &mut progress_step, + uninstall_total, + &format!("Removing shortcut {}", path.display()), + )?; + sys.remove_file_with_fallback(path, &effective_logger)? + } + JournalAction::ExecuteScript { .. } => { + effective_logger.info("skip_script_rollback", json!({})) + } + } + } + + for branch in &journal.purge.registry_branches { + let (root, subkey) = parse_registry_key(branch)?; + advance_gui_progress_step( + gui_progress, + &mut progress_step, + uninstall_total, + &format!("Purging registry branch {}", branch), + )?; + sys.delete_registry_tree(root, &subkey, &effective_logger)?; + } + for path in &journal.purge.paths { + let resolved = resolver.resolve(path); + advance_gui_progress_step( + gui_progress, + &mut progress_step, + uninstall_total, + &format!("Purging path {}", resolved.display()), + )?; + purge_path(&resolved, sys, &effective_logger)?; + } + + for (root, subkey) in deferred_uninstall_registry { + advance_gui_progress_step( + gui_progress, + &mut progress_step, + uninstall_total, + &format!("Removing uninstall registration {}", subkey), + )?; + sys.delete_registry_tree(root, &subkey, &effective_logger)?; + } + + if let Some(path) = deferred_self_delete { + if !is_rollback { + // Never wait for the window here: the cleanup helper must be + // spawned promptly so self-deletion is not deferred on the user. + finish_gui_progress( + gui_progress, + &format!("Finalizing removal of {}", journal.app_name), + false, + )?; + } + sys.spawn_cleanup_helper( + &path, + path.parent(), + &journal.app_name, + ui_mode, + automation, + logger.json, + &effective_logger, + )?; + } else if !is_rollback { + // During a rollback the install error handler owns the single + // terminal message on the shared progress session. + finish_gui_progress( + gui_progress, + &format!("{} uninstalled successfully!", journal.app_name), + false, + )?; + if ui_mode == UiMode::Gui && !automation { + sys.ui_report_uninstall_success(&journal.app_name)?; + } + } + + effective_logger.result("ok", json!({"journal":journal_path})); + let _ = fs::remove_file(journal_path); + trace_event( + if is_rollback { + "rollback_ok" + } else { + "uninstall_ok" + }, + json!({"journal": journal_path}), + ); + Ok(()) + })(); + + if let Err(err) = &result { + trace_event( + if is_rollback { + "rollback_error" + } else { + "uninstall_error" + }, + json!({"app_name": app_name, "error": err.to_string()}), + ); + // Rollback errors bubble up so the install error handler can compose + // the single combined failure message on the shared session. + if !is_rollback { + let _ = fail_gui_progress( + gui_progress, + &app_name, + "uninstall", + err, + journal.support_contact.as_deref(), + !automation, + ); + } + } + + result +} + +pub(crate) fn cleanup( + target_exe: PathBuf, + install_root: Option, + app_name: String, + ui_mode: UiMode, + automation: bool, + sys: &dyn Sys, + logger: &Logger, +) -> Result<(), AppError> { + trace_event( + "cleanup_start", + json!({"target_exe": &target_exe, "install_root": &install_root, "app_name": &app_name}), + ); + let effective_logger = if ui_mode == UiMode::Tui { + logger.quiet_clone() + } else { + logger.clone() + }; + let mut reboot_required = false; + for _ in 0..50 { + if !target_exe.exists() { + break; + } + if fs::remove_file(&target_exe).is_ok() { + break; + } + thread::sleep(Duration::from_millis(200)); + } + if target_exe.exists() { + sys.remove_file_with_fallback(&target_exe, &effective_logger)?; + reboot_required = target_exe.exists(); + } + if let Some(install_root) = install_root { + if install_root.exists() && fs::read_dir(&install_root)?.next().is_none() { + win::remove_directory_if_exists(&install_root, &effective_logger)?; + } + } + reboot_required |= sys.schedule_helper_self_cleanup(&effective_logger)?; + if ui_mode == UiMode::Gui && !automation { + if reboot_required { + if sys.ui_prompt_uninstall_reboot(&app_name)? { + sys.spawn_reboot(&effective_logger)?; + } + } else { + sys.ui_report_uninstall_success(&app_name)?; + } + } else if ui_mode == UiMode::Tui { + if reboot_required { + println!( + "{app_name} uninstalled successfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now." + ); + if sys.prompt_reboot_tui()? { + sys.spawn_reboot(&effective_logger)?; + } + } else { + println!("{app_name} uninstalled successfully!"); + } + } + Ok(()) +} + +pub(crate) fn spawn_cleanup_helper( + target_exe: &Path, + install_root: Option<&Path>, + app_name: &str, + ui_mode: UiMode, + automation: bool, + json: bool, + logger: &Logger, +) -> Result<(), AppError> { + let current_exe = std::env::current_exe()?; + let helper_path = std::env::temp_dir().join(format!( + "covenant-setup-cleanup-{}-{}.exe", + process::id(), + unique_ticks() + )); + logger.info( + "spawn_cleanup_helper", + json!({"helper":helper_path,"target_exe":target_exe,"install_root":install_root}), + ); + fs::copy(¤t_exe, &helper_path)?; + + let mut command = Command::new(&helper_path); + command.creation_flags(CREATE_NO_WINDOW); + if json { + command.arg("--json"); + } + if ui_mode == UiMode::Tui { + command.arg("--headless"); + } else if ui_mode == UiMode::Gui { + command.arg("--headed"); + } + if automation { + command.arg("--automation"); + } + command.arg("cleanup"); + command.arg("--target-exe"); + command.arg(target_exe); + if let Some(install_root) = install_root { + command.arg("--install-root"); + command.arg(install_root); + } + command.arg("--app-name"); + command.arg(app_name); + command.spawn()?; + Ok(()) +} + +pub(crate) fn total_uninstall_steps(journal: &Journal) -> usize { + journal.actions.len() + journal.purge.registry_branches.len() + journal.purge.paths.len() + 2 +} + +pub(crate) fn schedule_helper_self_cleanup(logger: &Logger) -> Result { + let self_exe = std::env::current_exe()?; + logger.info("schedule_helper_self_cleanup", json!({"path":self_exe})); + let delete_command = format!( + "Start-Sleep -Seconds 2; Remove-Item -LiteralPath '{}' -Force -ErrorAction SilentlyContinue", + powershell_single_quote(&self_exe.to_string_lossy()) + ); + let mut command = Command::new("powershell.exe"); + command.creation_flags(CREATE_NO_WINDOW); + command.arg("-NoProfile"); + command.arg("-WindowStyle"); + command.arg("Hidden"); + command.arg("-Command"); + command.arg(OsString::from(delete_command)); + if command.spawn().is_ok() { + return Ok(false); + } + win::remove_file_with_fallback(&self_exe, logger)?; + Ok(true) +} + +pub(crate) fn journal_requires_admin( + journal: &Journal, + resolver: &win::PathResolver, +) -> Result { + for action in &journal.actions { + match action { + JournalAction::CreateDirectory { path } + | JournalAction::CopyFile { + destination: path, .. + } + | JournalAction::CreateShortcut { path } => { + if resolver.requires_admin(path) { + return Ok(true); + } + } + JournalAction::WriteRegistry { root, .. } if matches!(root, RegistryRoot::Hklm) => { + return Ok(true); + } + _ => {} + } + } + for branch in &journal.purge.registry_branches { + let (root, _) = parse_registry_key(branch)?; + if matches!(root, RegistryRoot::Hklm) { + return Ok(true); + } + } + for path in &journal.purge.paths { + if resolver.requires_admin(&resolver.resolve(path)) { + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn purge_path(path: &Path, sys: &dyn Sys, logger: &Logger) -> Result<(), AppError> { + if !path.exists() { + return Ok(()); + } + if path.is_file() { + return sys.remove_file_with_fallback(path, logger); + } + for entry in fs::read_dir(path)? { + let entry = entry?; + let child = entry.path(); + if child.is_dir() { + purge_path(&child, sys, logger)?; + } else { + sys.remove_file_with_fallback(&child, logger)?; + } + } + win::remove_directory_if_exists(path, logger) +} + +pub(crate) fn parse_registry_key(input: &str) -> Result<(RegistryRoot, String), AppError> { + if let Some(rest) = input.strip_prefix("HKCU\\") { + return Ok((RegistryRoot::Hkcu, rest.to_string())); + } + if let Some(rest) = input.strip_prefix("HKLM\\") { + return Ok((RegistryRoot::Hklm, rest.to_string())); + } + Err(AppError::Message(format!( + "Unsupported registry root: {input}" + ))) +} + +pub(crate) fn is_uninstall_registry_key(subkey: &str) -> bool { + subkey.starts_with("Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\") +} + +pub(crate) fn push_unique_registry_branch( + branches: &mut Vec<(RegistryRoot, String)>, + root: RegistryRoot, + subkey: String, +) { + if !branches.iter().any(|(existing_root, existing_subkey)| { + *existing_root == root && *existing_subkey == subkey + }) { + branches.push((root, subkey)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + + #[test] + fn parse_registry_key_accepts_supported_roots_and_rejects_unknown_roots() { + assert_eq!( + parse_registry_key("HKCU\\Software\\Example").unwrap(), + (RegistryRoot::Hkcu, "Software\\Example".to_string()) + ); + assert_eq!( + parse_registry_key("HKLM\\Software\\Example").unwrap(), + (RegistryRoot::Hklm, "Software\\Example".to_string()) + ); + assert!(parse_registry_key("HKCR\\Software\\Example").is_err()); + } + + #[test] + fn uninstall_empty_journal_succeeds_without_actions() { + let temp = TestDir::new("uninstall-empty"); + let journal_path = temp.path().join("journal.json"); + let journal = Journal { + app_name: "Empty App".to_string(), + manifest_path: None, + actions: Vec::new(), + purge: PurgeSpec::default(), + support_contact: None, + }; + fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); + + uninstall( + &journal_path, + false, + UiMode::None, + true, + &WinSys, + &mut None, + &quiet_logger(), + false, + ) + .unwrap(); + } + + #[test] + fn purge_path_removes_nested_temp_tree_and_noops_when_missing() { + let temp = TestDir::new("purge-path"); + let root = temp.path().join("root"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("payload.txt"), b"payload").unwrap(); + + purge_path(&root, &WinSys, &quiet_logger()).unwrap(); + purge_path(&root, &WinSys, &quiet_logger()).unwrap(); + + assert!(!root.exists()); + } + + #[test] + fn purge_path_removes_single_file() { + let temp = TestDir::new("purge-file"); + let file = temp.path().join("payload.txt"); + fs::write(&file, b"payload").unwrap(); + + purge_path(&file, &WinSys, &quiet_logger()).unwrap(); + + assert!(!file.exists()); + } + + #[test] + fn uninstall_registry_key_detection_matches_only_uninstall_branch() { + assert!(is_uninstall_registry_key( + "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Sample" + )); + assert!(!is_uninstall_registry_key("Software\\Sample")); + } + + #[test] + fn push_unique_registry_branch_deduplicates_root_and_subkey() { + let mut branches = Vec::new(); + push_unique_registry_branch( + &mut branches, + RegistryRoot::Hkcu, + "Software\\App".to_string(), + ); + push_unique_registry_branch( + &mut branches, + RegistryRoot::Hkcu, + "Software\\App".to_string(), + ); + push_unique_registry_branch( + &mut branches, + RegistryRoot::Hklm, + "Software\\App".to_string(), + ); + + assert_eq!(branches.len(), 2); + } + + #[test] + fn cleanup_prompts_and_spawns_reboot_when_required_in_gui_mode() { + let temp = TestDir::new("cleanup-gui-reboot"); + let target_exe = temp.path().join("ghost.exe"); + // Don't create the file — cleanup() short-circuits on !exists(). + let sys = MockSys::new(); + sys.set_schedule_helper_self_cleanup(true); + sys.set_ui_prompt_uninstall_reboot(true); + cleanup( + target_exe, + None, + "Sample".to_string(), + UiMode::Gui, + false, + &sys, + &quiet_logger(), + ) + .unwrap(); + let calls = sys.recorded(); + assert!( + calls + .iter() + .any(|c| matches!(c, SysCall::ScheduleHelperSelfCleanup)) + ); + assert!( + calls + .iter() + .any(|c| matches!(c, SysCall::UiPromptUninstallReboot(name) if name == "Sample")) + ); + assert!(calls.iter().any(|c| matches!(c, SysCall::SpawnReboot))); + } + + // (e) + #[test] + fn cleanup_skips_reboot_when_user_declines() { + let temp = TestDir::new("cleanup-decline"); + let target_exe = temp.path().join("ghost.exe"); + let sys = MockSys::new(); + sys.set_schedule_helper_self_cleanup(true); + sys.set_ui_prompt_uninstall_reboot(false); + cleanup( + target_exe, + None, + "Sample".to_string(), + UiMode::Gui, + false, + &sys, + &quiet_logger(), + ) + .unwrap(); + let calls = sys.recorded(); + assert!( + calls + .iter() + .any(|c| matches!(c, SysCall::UiPromptUninstallReboot(_))) + ); + assert!(!calls.iter().any(|c| matches!(c, SysCall::SpawnReboot))); + } + + // (f) + #[test] + fn cleanup_tui_path_skips_prompt_when_no_reboot_needed() { + let temp = TestDir::new("cleanup-tui-no-reboot"); + let target_exe = temp.path().join("ghost.exe"); + let sys = MockSys::new(); + sys.set_schedule_helper_self_cleanup(false); + cleanup( + target_exe, + None, + "Sample".to_string(), + UiMode::Tui, + false, + &sys, + &quiet_logger(), + ) + .unwrap(); + let calls = sys.recorded(); + assert!( + !calls + .iter() + .any(|c| matches!(c, SysCall::UiPromptUninstallReboot(_))) + ); + assert!(!calls.iter().any(|c| matches!(c, SysCall::PromptRebootTui))); + assert!(!calls.iter().any(|c| matches!(c, SysCall::SpawnReboot))); + } + + // (g) + #[test] + fn uninstall_cancel_mid_run_keeps_journal_and_reports_uninstall_failure() { + let temp = TestDir::new("uninstall-cancel"); + let copied = temp.path().join("copied.bin"); + fs::write(&copied, b"x").unwrap(); + let journal_path = temp.path().join("journal.json"); + let journal = Journal { + app_name: "CancelApp".to_string(), + manifest_path: None, + actions: vec![JournalAction::CopyFile { + source: temp.path().join("source.bin"), + destination: copied.clone(), + }], + purge: PurgeSpec::default(), + support_contact: None, + }; + fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); + + let handle = Arc::new(Mutex::new(Vec::new())); + let sink = MockProgressSink { + calls: handle.clone(), + cancel_after_advances: Some(0), + }; + let mut progress: Option> = Some(Box::new(sink)); + let sys = MockSys::new(); + sys.set_is_elevated(true); + + let err = uninstall( + &journal_path, + false, + UiMode::None, + true, + &sys, + &mut progress, + &quiet_logger(), + false, + ) + .unwrap_err(); + + assert!(matches!(err, AppError::Cancelled)); + assert!( + journal_path.exists(), + "journal must survive a cancelled uninstall" + ); + assert!(copied.exists(), "no actions ran before the cancel tripped"); + let calls = handle.lock().unwrap().clone(); + assert!( + calls + .iter() + .any(|c| matches!(c, SinkCall::Fail { operation, .. } if operation == "uninstall")) + ); + } + + // (j) + #[test] + fn uninstall_calls_delete_registry_tree_for_recorded_actions_and_purge() { + let temp = TestDir::new("uninstall-deltree"); + let journal_path = temp.path().join("journal.json"); + let journal = Journal { + app_name: "RegApp".to_string(), + manifest_path: None, + actions: vec![JournalAction::WriteRegistry { + root: RegistryRoot::Hkcu, + subkey: "Software\\RegApp".to_string(), + name: "Alpha".to_string(), + }], + purge: PurgeSpec { + registry_branches: vec!["HKCU\\Software\\Purged".to_string()], + paths: vec![], + }, + support_contact: None, + }; + fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); + let sys = MockSys::new(); + sys.set_is_elevated(true); + uninstall( + &journal_path, + false, + UiMode::None, + true, + &sys, + &mut None, + &quiet_logger(), + false, + ) + .unwrap(); + let trees: Vec<_> = sys + .recorded() + .into_iter() + .filter_map(|c| match c { + SysCall::DeleteRegistryTree { subkey, .. } => Some(subkey), + _ => None, + }) + .collect(); + assert!(trees.iter().any(|s| s == "Software\\RegApp")); + assert!(trees.iter().any(|s| s == "Software\\Purged")); + } + + // (l) + #[test] + fn uninstall_calls_remove_file_with_fallback_for_copy_actions_and_shortcuts() { + let temp = TestDir::new("uninstall-rmfile"); + let copied = temp.path().join("copied.bin"); + let shortcut = temp.path().join("Shortcut.lnk"); + fs::write(&copied, b"x").unwrap(); + fs::write(&shortcut, b"x").unwrap(); + let journal_path = temp.path().join("journal.json"); + let journal = Journal { + app_name: "FileApp".to_string(), + manifest_path: None, + actions: vec![ + JournalAction::CopyFile { + source: temp.path().join("source.bin"), + destination: copied.clone(), + }, + JournalAction::CreateShortcut { + path: shortcut.clone(), + }, + ], + purge: PurgeSpec::default(), + support_contact: None, + }; + fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); + let sys = MockSys::new(); + sys.set_is_elevated(true); + uninstall( + &journal_path, + false, + UiMode::None, + true, + &sys, + &mut None, + &quiet_logger(), + false, + ) + .unwrap(); + let removed: Vec<_> = sys + .recorded() + .into_iter() + .filter_map(|c| match c { + SysCall::RemoveFileWithFallback(p) => Some(p), + _ => None, + }) + .collect(); + assert!(removed.iter().any(|p| p == &copied)); + assert!(removed.iter().any(|p| p == &shortcut)); + } + + // (m): the self-delete branch is triggered when a CopyFile destination + // matches the current exe — uninstall() then calls spawn_cleanup_helper. + #[test] + fn uninstall_defers_self_delete_to_spawn_cleanup_helper() { + let temp = TestDir::new("uninstall-self"); + let current_exe = std::env::current_exe().unwrap(); + let journal_path = temp.path().join("journal.json"); + let journal = Journal { + app_name: "SelfApp".to_string(), + manifest_path: None, + actions: vec![JournalAction::CopyFile { + source: temp.path().join("source.exe"), + destination: current_exe.clone(), + }], + purge: PurgeSpec::default(), + support_contact: None, + }; + fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); + let sys = MockSys::new(); + sys.set_is_elevated(true); + uninstall( + &journal_path, + false, + UiMode::None, + true, + &sys, + &mut None, + &quiet_logger(), + false, + ) + .unwrap(); + let helper = sys + .recorded() + .into_iter() + .find(|c| matches!(c, SysCall::SpawnCleanupHelper { .. })) + .expect("expected SpawnCleanupHelper recorded"); + match helper { + SysCall::SpawnCleanupHelper { + target_exe, + app_name, + .. + } => { + assert!(same_path(&target_exe, ¤t_exe)); + assert_eq!(app_name, "SelfApp"); + } + _ => unreachable!(), + } + } + + // (n) +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..fc5287b --- /dev/null +++ b/src/util.rs @@ -0,0 +1,176 @@ +use crate::{AppError, Logger}; +use serde_json::json; +use std::fs; +use std::io::{self, Write}; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +pub(crate) fn unique_ticks() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +pub(crate) fn powershell_single_quote(value: &str) -> String { + value.replace('\'', "''") +} + +pub(crate) fn spawn_reboot(logger: &Logger) -> Result<(), AppError> { + logger.info("spawn_reboot", json!({})); + let mut command = Command::new("shutdown.exe"); + command.creation_flags(CREATE_NO_WINDOW); + command.args(["/r", "/t", "0"]); + command.spawn()?; + Ok(()) +} + +pub(crate) fn prompt_reboot_tui() -> Result { + print!("Restart now? [y/N]: "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(matches!( + input.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +pub(crate) fn sanitize_registry_component(value: &str) -> String { + let sanitized: String = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "covenant_setup".to_string() + } else { + sanitized + } +} + +pub(crate) fn same_path(left: &Path, right: &Path) -> bool { + if let (Ok(left), Ok(right)) = (fs::canonicalize(left), fs::canonicalize(right)) { + return normalize_path_for_compare(&left) == normalize_path_for_compare(&right); + } + normalize_path_for_compare(left) == normalize_path_for_compare(right) +} + +pub(crate) fn normalize_path_for_compare(path: &Path) -> String { + let mut value = path.to_string_lossy().replace('/', "\\"); + if let Some(rest) = value.strip_prefix("\\\\?\\UNC\\") { + value = format!("\\\\{rest}"); + } else if let Some(rest) = value.strip_prefix("\\\\?\\") { + value = rest.to_string(); + } + while value.ends_with('\\') && !is_windows_root(&value) { + value.pop(); + } + value.to_ascii_lowercase() +} + +pub(crate) fn is_windows_root(path: &str) -> bool { + let bytes = path.as_bytes(); + (bytes.len() == 3 && bytes[1] == b':' && bytes[2] == b'\\') + || (path.starts_with("\\\\") && path[2..].matches('\\').count() <= 1) +} + +pub(crate) fn absolutize(base: Option<&Path>, value: &str) -> PathBuf { + let candidate = PathBuf::from(value); + if candidate.is_absolute() { + candidate + } else { + base.unwrap_or_else(|| Path::new(".")).join(candidate) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::*; + + #[test] + fn sanitize_registry_component_replaces_punctuation_and_defaults_empty_input() { + assert_eq!(sanitize_registry_component(""), "covenant_setup"); + assert_eq!( + sanitize_registry_component("Vendor App: 1.0/alpha"), + "Vendor_App__1_0_alpha" + ); + assert_eq!(sanitize_registry_component("AZaz09-_"), "AZaz09-_"); + } + + #[test] + fn normalize_path_for_compare_handles_case_slashes_and_verbatim_prefixes() { + assert_eq!( + normalize_path_for_compare(Path::new("C:/Apps/Example/")), + "c:\\apps\\example" + ); + assert_eq!( + normalize_path_for_compare(Path::new(r"\\?\C:\Apps\Example")), + "c:\\apps\\example" + ); + assert_eq!( + normalize_path_for_compare(Path::new(r"\\?\UNC\server\share\Example")), + r"\\server\share\example" + ); + } + + #[test] + fn path_root_and_absolutize_helpers_handle_expected_shapes() { + assert!(is_windows_root("C:\\")); + assert!(is_windows_root("\\\\server\\share")); + assert!(!is_windows_root("C:\\Apps")); + assert_eq!( + absolutize(Some(Path::new("C:\\Base")), "relative\\file.txt"), + PathBuf::from("C:\\Base\\relative\\file.txt") + ); + assert_eq!( + absolutize(Some(Path::new("C:\\Base")), "D:\\absolute\\file.txt"), + PathBuf::from("D:\\absolute\\file.txt") + ); + } + + #[test] + fn same_path_uses_normalized_fallback_for_missing_paths() { + assert!(same_path( + Path::new("C:/Missing/Example/"), + Path::new(r"\\?\C:\Missing\Example") + )); + assert!(!same_path( + Path::new("C:/Missing/Example"), + Path::new("C:/Missing/Other") + )); + } + + #[test] + fn same_path_uses_canonicalized_existing_paths() { + let temp = TestDir::new("same-path"); + let nested = temp.path().join("nested"); + let file = nested.join("payload.txt"); + fs::create_dir_all(&nested).unwrap(); + fs::write(&file, b"payload").unwrap(); + + assert!(same_path(&file, &nested.join(".").join("payload.txt"))); + } + + #[test] + fn powershell_single_quote_doubles_embedded_quotes() { + assert_eq!( + powershell_single_quote("C:\\Alice's App"), + "C:\\Alice''s App" + ); + } + + #[test] + fn unique_ticks_returns_nonzero_timestamp() { + assert!(unique_ticks() > 0); + } +} diff --git a/src/win.rs b/src/win.rs deleted file mode 100644 index 884e358..0000000 --- a/src/win.rs +++ /dev/null @@ -1,1010 +0,0 @@ -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::Pipes::PeekNamedPipe; -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 program_files_x86: PathBuf, - pub local_app_data: PathBuf, - pub desktop: PathBuf, - admin_roots: Vec, -} - -impl PathResolver { - pub fn new(logger: &Logger) -> Result { - 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, - program_files_x86, - 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( - "{ProgramFilesX86}", - &self.program_files_x86.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) -> Self { - let admin_roots = build_admin_roots(&roots.iter().collect::>()); - Self { - program_files_x64: PathBuf::new(), - program_files_x86: PathBuf::new(), - local_app_data: PathBuf::new(), - desktop: PathBuf::new(), - admin_roots, - } - } -} - -fn build_admin_roots(roots: &[&PathBuf]) -> Vec { - 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 { - 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::()), - std::mem::size_of::() as u32, - &mut returned, - ) - }; - logger.unsafe_exit("GetTokenInformation", json!({"returned": returned})); - close_handle(token, logger)?; - info_result?; - if returned < std::mem::size_of::() 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::>() - .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(()) -} - -/// Returns how many bytes are ready to read on `pipe` without blocking. Used -/// by the GUI progress session to poll for a `cancel_request` line: leaving a -/// blocking read pending on a synchronous pipe handle would serialize against -/// (and stall) progress writes on the same handle. -pub fn peek_named_pipe_available(pipe: &fs::File, logger: &Logger) -> Result { - use std::os::windows::io::AsRawHandle; - let mut available = 0u32; - logger.unsafe_enter("PeekNamedPipe", json!({})); - let result = unsafe { - PeekNamedPipe( - HANDLE(pipe.as_raw_handle()), - None, - 0, - None, - Some(&mut available), - None, - ) - }; - logger.unsafe_exit( - "PeekNamedPipe", - json!({"ok": result.is_ok(), "available": available}), - ); - result?; - Ok(available) -} - -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", json!({})); - let persist: IPersistFile = link.cast()?; - logger.unsafe_exit("Interface::cast", 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, 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 { - 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 { - 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, -} - -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::(), - self.inner.len() * std::mem::size_of::(), - ) - } - } -} - -#[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 resolve_replaces_every_known_folder_token() { - let r = PathResolver { - program_files_x64: PathBuf::from("C:\\Program Files"), - program_files_x86: PathBuf::from("C:\\Program Files (x86)"), - local_app_data: PathBuf::from("C:\\Users\\alice\\AppData\\Local"), - desktop: PathBuf::from("C:\\Users\\alice\\Desktop"), - admin_roots: Vec::new(), - }; - assert_eq!( - r.resolve("{ProgramFilesX64}\\App\\bin"), - PathBuf::from("C:\\Program Files\\App\\bin") - ); - assert_eq!( - r.resolve("{ProgramFilesX86}\\Vendor\\app.exe"), - PathBuf::from("C:\\Program Files (x86)\\Vendor\\app.exe") - ); - assert_eq!( - r.resolve("{LocalAppData}\\Vendor"), - PathBuf::from("C:\\Users\\alice\\AppData\\Local\\Vendor") - ); - assert_eq!( - r.resolve("{Desktop}\\App.lnk"), - PathBuf::from("C:\\Users\\alice\\Desktop\\App.lnk") - ); - } - - #[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::() - ); - 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 { - 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 - } -} diff --git a/src/win/elevation.rs b/src/win/elevation.rs new file mode 100644 index 0000000..90bee65 --- /dev/null +++ b/src/win/elevation.rs @@ -0,0 +1,92 @@ +use super::*; + +pub(crate) 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 { + 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::()), + std::mem::size_of::() as u32, + &mut returned, + ) + }; + logger.unsafe_exit("GetTokenInformation", json!({"returned": returned})); + close_handle(token, logger)?; + info_result?; + if returned < std::mem::size_of::() 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::>() + .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(()) +} diff --git a/src/win/fs_ops.rs b/src/win/fs_ops.rs new file mode 100644 index 0000000..9445eaf --- /dev/null +++ b/src/win/fs_ops.rs @@ -0,0 +1,83 @@ +use super::*; + +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(()) +} diff --git a/src/win/mod.rs b/src/win/mod.rs new file mode 100644 index 0000000..7eb2e79 --- /dev/null +++ b/src/win/mod.rs @@ -0,0 +1,494 @@ +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::Pipes::PeekNamedPipe; +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}; + +mod elevation; +mod fs_ops; +mod path; +mod pipe; +mod registry; +mod restart_manager; +mod shortcut; + +pub(crate) use elevation::*; +pub(crate) use fs_ops::*; +pub(crate) use path::*; +pub(crate) use pipe::*; +pub(crate) use registry::*; +pub(crate) use restart_manager::*; +pub(crate) use shortcut::*; +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 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, +} + +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::(), + self.inner.len() * std::mem::size_of::(), + ) + } + } +} +#[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 resolve_replaces_every_known_folder_token() { + let r = PathResolver { + program_files_x64: PathBuf::from("C:\\Program Files"), + program_files_x86: PathBuf::from("C:\\Program Files (x86)"), + local_app_data: PathBuf::from("C:\\Users\\alice\\AppData\\Local"), + desktop: PathBuf::from("C:\\Users\\alice\\Desktop"), + admin_roots: Vec::new(), + }; + assert_eq!( + r.resolve("{ProgramFilesX64}\\App\\bin"), + PathBuf::from("C:\\Program Files\\App\\bin") + ); + assert_eq!( + r.resolve("{ProgramFilesX86}\\Vendor\\app.exe"), + PathBuf::from("C:\\Program Files (x86)\\Vendor\\app.exe") + ); + assert_eq!( + r.resolve("{LocalAppData}\\Vendor"), + PathBuf::from("C:\\Users\\alice\\AppData\\Local\\Vendor") + ); + assert_eq!( + r.resolve("{Desktop}\\App.lnk"), + PathBuf::from("C:\\Users\\alice\\Desktop\\App.lnk") + ); + } + + #[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::() + ); + 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 { + 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 + } +} diff --git a/src/win/path.rs b/src/win/path.rs new file mode 100644 index 0000000..df22f82 --- /dev/null +++ b/src/win/path.rs @@ -0,0 +1,120 @@ +use super::*; + +pub struct PathResolver { + pub program_files_x64: PathBuf, + pub program_files_x86: PathBuf, + pub local_app_data: PathBuf, + pub desktop: PathBuf, + pub(crate) admin_roots: Vec, +} + +impl PathResolver { + pub fn new(logger: &Logger) -> Result { + 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, + program_files_x86, + 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( + "{ProgramFilesX86}", + &self.program_files_x86.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) -> Self { + let admin_roots = build_admin_roots(&roots.iter().collect::>()); + Self { + program_files_x64: PathBuf::new(), + program_files_x86: PathBuf::new(), + local_app_data: PathBuf::new(), + desktop: PathBuf::new(), + admin_roots, + } + } +} + +fn build_admin_roots(roots: &[&PathBuf]) -> Vec { + 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 +fn known_folder(id: &windows::core::GUID, logger: &Logger) -> Result { + 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 +} + +pub(crate) fn pwstr_to_path(raw: PWSTR, logger: &Logger) -> Result { + 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)) + } +} diff --git a/src/win/pipe.rs b/src/win/pipe.rs new file mode 100644 index 0000000..6558afd --- /dev/null +++ b/src/win/pipe.rs @@ -0,0 +1,27 @@ +use super::*; + +/// Returns how many bytes are ready to read on `pipe` without blocking. Used +/// by the GUI progress session to poll for a `cancel_request` line: leaving a +/// blocking read pending on a synchronous pipe handle would serialize against +/// (and stall) progress writes on the same handle. +pub fn peek_named_pipe_available(pipe: &fs::File, logger: &Logger) -> Result { + use std::os::windows::io::AsRawHandle; + let mut available = 0u32; + logger.unsafe_enter("PeekNamedPipe", json!({})); + let result = unsafe { + PeekNamedPipe( + HANDLE(pipe.as_raw_handle()), + None, + 0, + None, + Some(&mut available), + None, + ) + }; + logger.unsafe_exit( + "PeekNamedPipe", + json!({"ok": result.is_ok(), "available": available}), + ); + result?; + Ok(available) +} diff --git a/src/win/registry.rs b/src/win/registry.rs new file mode 100644 index 0000000..1f728b2 --- /dev/null +++ b/src/win/registry.rs @@ -0,0 +1,72 @@ +use super::*; + +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") +} +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, + } +} diff --git a/src/win/restart_manager.rs b/src/win/restart_manager.rs new file mode 100644 index 0000000..ea30bf8 --- /dev/null +++ b/src/win/restart_manager.rs @@ -0,0 +1,80 @@ +use super::*; + +pub(crate) fn get_locking_processes(path: &Path, logger: &Logger) -> Result, 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") +} diff --git a/src/win/shortcut.rs b/src/win/shortcut.rs new file mode 100644 index 0000000..66c38cf --- /dev/null +++ b/src/win/shortcut.rs @@ -0,0 +1,60 @@ +use super::*; + +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", json!({})); + let persist: IPersistFile = link.cast()?; + logger.unsafe_exit("Interface::cast", 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 +}