diff --git a/.gitignore b/.gitignore index c4f2643..2212214 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ /vm/self-test/payload/ **/bin/ **/obj/ + +vm/ diff --git a/CLAUDE.md b/CLAUDE.md index 1ea717f..e2ad5db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,12 +24,24 @@ cargo run -- install examples/install.toml --json cargo run -- uninstall examples/journal.json --json ``` -No automated test suite exists yet. Manual testing uses the example manifest (`examples/install.toml`). +No Rust automated test suite exists yet beyond the in-tree `#[cfg(test)]` +unit tests (`cargo test`, 96 tests). C# UI unit tests live in a sibling +project and run via: + +```bash +dotnet test ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj +``` + +Real Win32/UAC/registry boundaries are validated by the Vagrant harness +(`scripts/run-windows-vm-coverage.ps1`) — see +`docs/integration-tests-architecture.md`. Manual interactive testing uses +the example manifest (`examples/install.toml`). ## Architecture -**Two source files:** +**Three source files:** - `src/main.rs` — CLI (clap derive), manifest parsing, install/uninstall/package logic, journaling, UI (TUI/GUI/JSON), elevation handling +- `src/sys.rs` — `Sys` trait abstracting every external boundary (Win32 elevation/registry/MoveFileEx fallback, reboot, cleanup-helper spawn, embedded-bundle probe, GUI prompts, optional `ProgressSink` injection). `WinSys` is the production implementation that delegates to `crate::win::*`, `crate::ui::*`, and the local helpers; `MockSys` (in `mod tests`) records every call for unit tests. - `src/win.rs` — All Win32 FFI isolated here. Every `unsafe` block is bracketed with `logger.unsafe_enter()`/`unsafe_exit()` calls. Contains `PathResolver` for known-folder token resolution, file/directory/registry/shortcut operations, Restart Manager queries, and elevation checks. **Three operational modes (CLI subcommands):** @@ -51,8 +63,13 @@ No automated test suite exists yet. Manual testing uses the example manifest (`e ## Conventions - All Win32 calls go in `src/win.rs`, never in `main.rs` +- All external boundaries (`win::*`, `ui::*` prompts, reboot/cleanup-helper spawning, embedded-bundle probe) flow through the `Sys` trait in `src/sys.rs` so orchestration code can be unit-tested with `MockSys` - UTF-16 conversion uses the `Utf16Arg` wrapper type - Registry always uses `KEY_WOW64_64KEY` for explicit 64-bit access - Path tokens (`{ProgramFilesX64}`, etc.) are resolved at runtime, never hardcoded - Subprocess calls use `CREATE_NO_WINDOW` flag - Rust edition 2024 + +## VM coverage harness + +`scripts\run-windows-vm-coverage.ps1` walks every scenario directory under `vm\\install.toml` and delegates per-scenario in-guest assertions to `scripts\windows-vm\coverage\.ps1`. The bundled scenarios (`self-test`, `uac`, `hklm-registry`, `reboot`, `bundled-exec`) exercise the elevation, MoveFileEx pending-rename, HKLM-registry, and embedded-bundle code paths. The harness builds the release binary and dispatches scenarios in-place; pass `-SkipBuild` to reuse a prior build. diff --git a/Cargo.toml b/Cargo.toml index 8f2aeb3..02c77a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ windows = { version = "0.62.2", features = [ "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", - "Win32_System_Diagnostics_ToolHelp", "Win32_System_RestartManager", "Win32_System_Registry", "Win32_System_Threading", diff --git a/README.md b/README.md index f8faaec..252602c 100644 --- a/README.md +++ b/README.md @@ -46,15 +46,15 @@ Current output: That installer is a single executable. The manifest and payload files are embedded into the binary and extracted to a temporary working directory at runtime. -The Rust build publishes a self-contained C# WinForms UI helper and embeds it into the Rust executable. Building the installer therefore requires the .NET SDK in addition to Rust/Cargo, but the packaged installer does not require a .NET runtime to be preinstalled on the target machine. +When the .NET SDK is available, the Rust build publishes a self-contained C# WinForms UI helper and embeds it into the Rust executable. Without that helper the installer still builds, but `--headed` falls back to terminal progress. ## Install and Uninstall Model Direct engine commands: ```powershell -cargo run -- install path\to\install.toml -cargo run -- uninstall path\to\journal.json +cargo run -- --headless install path\to\install.toml +cargo run -- --headless uninstall path\to\journal.json ``` Packaged installer behavior: @@ -74,18 +74,15 @@ Installed-app uninstall behavior: ## UI Behavior -There is now one installer/uninstaller path. UI mode is chosen by context unless explicitly overridden. +There is now one installer/uninstaller path. UI mode must be explicit for interactive runs. Explicit flags: - `--headless`: force TUI - `--headed`: force GUI +- `--json`: suppress UI and emit machine-readable events -Current automatic behavior: - -- If launched from PowerShell / `pwsh`, uninstall prefers TUI -- If launched from Windows GUI context, install/uninstall prefer GUI -- Otherwise the engine can run without extra UI +If `--headed` is requested but the C# UI helper is not bundled and no `Covenant.Setup.Ui.exe` sidecar exists next to the installer, the engine falls back to `--headless`. ### GUI @@ -125,6 +122,7 @@ The sample manifest lives at [`examples/install.toml`](C:\Users\jasonross\worksp - Core engine flow is in [`src/main.rs`](C:\Users\jasonross\workspace\covenant-setup\src\main.rs) - Windows FFI wrappers are isolated in [`src/win.rs`](C:\Users\jasonross\workspace\covenant-setup\src\win.rs) +- External-boundary calls (Win32, GUI prompts, reboot/cleanup spawning, embedded-bundle probe) flow through the `Sys` trait in [`src/sys.rs`](C:\Users\jasonross\workspace\covenant-setup\src\sys.rs); the production `WinSys` impl delegates to the real subsystems while `MockSys` records every call for unit tests - Journaling currently records declared actions through `DeclaredTracker` - The implementation is Windows-specific @@ -156,12 +154,12 @@ Day-to-day work happens on Windows. This section documents an opt-in path for ty Prerequisites (Ubuntu 24.04 names): -- `dotnet-sdk-10.0` — the C# UI build script (`build.rs`) invokes `dotnet publish`. +- `dotnet-sdk-10.0` — optional for embedding the C# UI helper; without it, headed mode falls back to headless. - `mingw-w64` — provides the `x86_64-w64-mingw32-*` toolchain that the `windows-gnu` target links against. - `wine` — runs the resulting test binary. Already wired up via `runner = "wine"` in [`.cargo/config.toml`](.cargo/config.toml) for the `x86_64-pc-windows-gnu` target only. - `rustup target add x86_64-pc-windows-gnu`. -The dotnet SDK needs `EnableWindowsTargeting=true` to cross-build a `net8.0-windows` project from Linux: +The dotnet SDK needs `EnableWindowsTargeting=true` to cross-build a `net10.0-windows` project from Linux: ```bash EnableWindowsTargeting=true cargo check --target x86_64-pc-windows-gnu @@ -207,4 +205,8 @@ Notes: - Set `COVENANT_HYPERV_SWITCH` to the Hyper-V virtual switch name you want Vagrant to use. - The harness writes its verification artifact to `dist\vagrant-self-test\guest-result.json`. - Use `-SkipViewer` if you do not want the harness to open the Hyper-V console window. + +### VM Coverage Harness + +In addition to the single-scenario smoke test, [`scripts/run-windows-vm-coverage.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\run-windows-vm-coverage.ps1) walks every scenario manifest under `vm\\install.toml` (`self-test`, `uac`, `hklm-registry`, `reboot`, `bundled-exec`) and delegates the in-guest assertions to [`scripts\windows-vm\coverage\.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\windows-vm\coverage). The scenarios exercise the elevation, MoveFileEx pending-rename / Restart Manager, HKLM-only registry, and bundled embedded-installer code paths that the unit tests stub out via `MockSys`. - Use `-HaltAfter` or `-DestroyAfter` if you want the harness to stop the VM after the test run. diff --git a/build.rs b/build.rs index 62cafd4..b46c56f 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::process::Command; fn main() { + println!("cargo:rustc-check-cfg=cfg(covenant_setup_embedded_ui)"); publish_csharp_ui(); embed_manifest(embed_manifest::new_manifest("Comctl32")) .expect("unable to embed application manifest"); @@ -39,15 +40,31 @@ fn publish_csharp_ui() { .arg("-p:DebugSymbols=false") .arg("-o") .arg(&publish_dir) - .status() - .expect("failed to launch dotnet publish for C# UI"); + .status(); + let status = match status { + Ok(status) => status, + Err(err) => { + println!( + "cargo:warning=C# UI helper was not bundled because dotnet publish could not start: {err}" + ); + return; + } + }; if !status.success() { - panic!("dotnet publish failed for C# UI with {status}"); + println!( + "cargo:warning=C# UI helper was not bundled because dotnet publish failed with {status}" + ); + return; } let ui_exe = publish_dir.join("Covenant.Setup.Ui.exe"); if !ui_exe.exists() { - panic!("C# UI publish did not produce {}", ui_exe.display()); + println!( + "cargo:warning=C# UI helper was not bundled because dotnet publish did not produce {}", + ui_exe.display() + ); + return; } + println!("cargo:rustc-cfg=covenant_setup_embedded_ui"); println!("cargo:rustc-env=COVENANT_SETUP_UI_EXE={}", ui_exe.display()); } diff --git a/docs/implementation-notes.md b/docs/implementation-notes.md new file mode 100644 index 0000000..b5a3c68 --- /dev/null +++ b/docs/implementation-notes.md @@ -0,0 +1,296 @@ +# Code Review: feat-mvp + + ## Overview + +A Windows installer engine that: parses a TOML manifest → executes mutations via Win32 → journals each action → reverses on uninstall. Three CLI verbs (package, install, uninstall) plus a hidden cleanup. Adds a single-file packager that appends payload+index+magic-footer onto the EXE, an out-of-process WinForms GUI (C# binary embedded at build time, talks to Rust over a named pipe, JSON-per-line), a TUI spinner mode, and a --json IPC mode. Tracking goes through the MutationTracker trait (DeclaredTracker is the only impl, matching the MVP spec). + + ## What's Solid + + - Adherence to the MVP spec: W APIs everywhere, KEY_WOW64_64KEY set on every RegCreateKeyExW, SHGetKnownFolderPath for {ProgramFilesX64} / {LocalAppData} / {Desktop}, Restart Manager (RmStartSession/RmGetList) + MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) fallback for locked files, runas elevation via ShellExecuteW, exit code 33 for elevation-required. + - Glass-box logging: every unsafe block is bracketed by unsafe_enter/unsafe_exit (src/win.rs), and trace_event writes JSONL heartbeat for debugging. This is the most distinctive strength of the code. + - Module discipline: src/win.rs owns 100% of FFI; no unsafe leaks into main.rs. Utf16Arg correctly null-terminates and exposes as_bytes() with terminator (right for REG_SZ). + - Self-deletion strategy: spawn helper EXE → original exits → helper deletes target + schedules its own cleanup via PowerShell + MoveFileEx reboot fallback. Sound design. + - Bundle format (src/main.rs:577–687): payload + length-prefixed JSON index + payload-len + magic footer is a clean append-only design that survives any leading + binary signing layout. + + ## Correctness Issues + + - [x] path_requires_admin (src/main.rs:1844) hardcodes c:\\program files / c:\\windows. This contradicts the MVP requirement "Hardcoded paths are forbidden" and the convention in CLAUDE.md. Compare against FOLDERID_ProgramFiles* / FOLDERID_Windows from PathResolver. Will misdetect on a non-C: Windows install. Resolved: admin checks now route through PathResolver roots. + - [x] relaunch_as_admin (src/win.rs:162): std::env::args().skip(1).collect().join(" ") does not Windows-quote arguments. A manifest path with spaces ("C:\Users\Alice's Apps\install.toml") survives as separate tokens after runas. Use CommandLineToArgvW-compatible quoting. Resolved: args are quoted with CommandLineToArgvW-compatible rules. + - [x] select_ui defaults are inverted (src/main.rs:1474): when stdout is a terminal but parent isn't PowerShell, returns UiMode::None (silent install with no progress); when stdout is not a terminal (piped/redirected), returns UiMode::Gui. So installer install foo.toml | tee log.txt pops a GUI. Default for terminals should be TUI. Resolved differently: UI mode is now explicit; --json suppresses UI and --headed falls back to headless if GUI is unavailable. + - [x] is_bundled_runtime_invocation (src/main.rs:1460) scans all args for package|install|uninstall|cleanup. If any value (e.g. a path, hidden value, future + positional) ever equals one of these strings, routing breaks. Inspect only the first non-flag positional. Resolved: the pre-clap routing helper was removed; clap now parses optional subcommands and bundled mode is selected only when no subcommand is present and an embedded bundle exists. + - [x] fail_gui_progress vs finish_gui_progress (src/main.rs:1547,1558) are identical — both call progress.finish(message). There's no fail message type to the C# side, + so a failed install gets a "completed" UX. Either add a "fail" message variant or red-state the C# form on a known sentinel. Resolved: GUI progress now has a fail IPC message, persistent failure UX, and errata export. + - [x] install_uninstaller records seven separate WriteRegistry actions for the ARP key (src/main.rs:962), but uninstall short-circuits to delete_registry_tree on first match (src/main.rs:1086). Functionally fine; the other six are dead journal entries. Either record one branch action or deduplicate during rollback. Resolved: uninstall defers each uninstall-registry branch only once. + - [x] remove_directory_if_exists (src/win.rs:228) silently swallows ERROR_DIR_NOT_EMPTY and returns Ok without surfacing it to the journal/UI. Worth at least a warn-level event so users know residue exists. Resolved: not-empty directories emit a `remove_directory_deferred` event with reason `not_empty`. + - [x] same_path (src/main.rs:1834) is a lowercased string compare. Doesn't handle \\?\ prefixes, 8.3 names, or junctions. Use dunce::canonicalize / + std::fs::canonicalize with a string-fallback for missing paths. Resolved: same_path canonicalizes both sides when possible and falls back to normalized string comparison with verbatim-prefix handling. + - [x] collect_bundle_files_recursive (src/main.rs:548) has no exclude list. Re-running package from a directory that was previously installed-from will pick up + journal.json (and any temp scratch) into the new bundle. Resolved: bundle collection skips known generated artifacts and the current output installer path. + - [x] run_bundled_installer (src/main.rs:721) has a single-variant enum RuntimeMode::Bundled; match arm is dead branching. Either drop the enum or commit to + multi-mode. Resolved: RuntimeMode was removed. + - [x] Typo replicated: "uninstalled sucessfully" (missing 's') in src/main.rs:1235 and src/ui.rs:111. Resolved. + + ## Architecture / Style + + - [ ] main.rs is 1856 lines mixing CLI, manifest types, journal types, install/uninstall logic, bundle (de)serialization, IPC plumbing, and a dozen helpers. Split into manifest.rs, journal.rs, bundle.rs, install.rs, uninstall.rs, cli.rs. The ui.rs / win.rs split is good — extend that pattern. + - [x] Manual arg parsing in parse_ui_preferences (src/main.rs:1433) duplicates clap. The bundled-runtime detection happens before clap parses, which is why this exists, but the duplication of the subcommand keyword list is brittle. Consider running Cli::try_parse_from in detect-only mode first, or feed clap a pre-stripped args vector. Resolved: parse_ui_preferences was removed and clap parses the optional subcommand path directly. + - [x] start_gui_progress ignores its app_name parameter (src/main.rs:1505); &format!("{title}") is a no-op clone. Remove the dead arg. Resolved. + - [x] UiPhase enum is effectively unused in select_ui — both arms return UiMode::None. Resolved: UiPhase was removed. + - [x] Many effective_logger.info("create_directory", json!({"path":path})) blocks are near-clones. A step! macro or per-action helper would shrink the install loop substantially. Resolved partially: repeated progress-step increment/advance plumbing now goes through `advance_gui_progress_step`. + + ## Tests + + - [x] Zero automated tests (CLAUDE.md confirms). The smoke is end-to-end on a Vagrant Windows VM, which is good for integration but doesn't catch regressions cheaply. Resolved: unit tests now cover bundle/journal helpers plus Win32 quoting/admin-root matching. + Easy unit-test wins, all OS-portable: + - [x] Bundle round-trip (append_embedded_bundle → read_embedded_bundle) + - [x] Journal serde round-trip + - [x] parse_registry_key (HKCU, HKLM, error) + - [x] sanitize_registry_component (empty input, mixed punctuation) + - [x] same_path / normalize_path_for_compare + - [x] path_requires_admin (after de-hardcoding) + - [x] Utf16Arg::as_bytes length math + - [x] is_bundled_runtime_invocation truth table. Resolved by removing the helper and testing clap parsing for bundled flags without manual preparse. + + ## Security + + Threat model is "developer authors a trusted manifest" — under that assumption, mostly fine. Concrete items: + + - Bundle has no integrity check. Anyone who can write to the EXE can swap the appended payload without breaking Authenticode signing of the original PE. For a shipping installer, hash the embedded bundle into the binary at build time and verify on read. + - execute_script is by-design arbitrary code execution under the elevation context — document this in the manifest schema. Consider a --no-scripts switch for + paranoid environments. + - PowerShell single-quote escape (powershell_single_quote) is correct for single-quoted strings. Good. + - Registry component sanitizer maps anything outside [A-Za-z0-9_-] to _. Good against subkey-traversal injection. + - extract_embedded_bundle writes to %TEMP%\covenant-setup\{stem}-{pid} and remove_dir_alls any existing path first — TOCTOU window if a hostile user has write access to that temp tree. Low risk on Windows ACLs, but consider creating with a random suffix. + + ## Performance + + - read_embedded_bundle reads the entire EXE into memory (read_to_end, src/main.rs:617). For an installer with a multi-hundred-MB payload, this doubles peak RSS. + Seek to len - 32 to read footer, then seek back to payload_offset and stream into the extraction directory. + - extract_embedded_bundle clones each file's bytes from the in-memory bundle to disk; combined with the above, the payload is held in RAM twice. Streaming + extraction would fix both. + + Verdict + + For an MVP this is in good shape and tracks the spec closely. The biggest deltas to address before calling it MVP-complete: + + - [x] De-hardcode path_requires_admin (spec violation). + - [x] Quote args in relaunch_as_admin (real-world breakage on paths-with-spaces). + - [x] Fix the select_ui default (silent installs from terminals, GUIs from pipes). Resolved by requiring explicit UI mode and honoring --json. + - [x] Add a handful of unit tests for the pure-Rust helpers — bundle round-trip and journal serde at minimum. + - [x] Decide whether fail_gui_progress should actually surface failure UX. + + +### Q: How would you suggest fixing item 1? + +**A:** The fix is to resolve the admin-required roots at runtime through PathResolver (same pattern as {ProgramFilesX64} etc.), then test ancestry against those resolved roots. + + Concrete change + +1. Add the missing known folders to PathResolver (src/win.rs:39): + +```rust +use windows::Win32::UI::Shell::{ + FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64, + FOLDERID_ProgramFilesX86, FOLDERID_Windows, /* … */ +}; + +pub struct PathResolver { + pub program_files_x64: PathBuf, + pub program_files_x86: PathBuf, + pub windows_dir: PathBuf, + pub local_app_data: PathBuf, + pub desktop: PathBuf, + admin_roots: Vec, // pre-lowercased for matching +} + +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 = [&program_files_x64, &program_files_x86, &windows_dir] + .iter() + .map(|p| p.to_string_lossy().to_ascii_lowercase()) + .collect(); + Ok(Self { + program_files_x64, + program_files_x86, + windows_dir, + local_app_data: known_folder(&FOLDERID_LocalAppData, logger)?, + desktop: known_folder(&FOLDERID_Desktop, logger)?, + admin_roots, + }) + } + + pub fn requires_admin(&self, path: &Path) -> bool { + let candidate = path.to_string_lossy().to_ascii_lowercase().replace('/', "\\"); + self.admin_roots.iter().any(|root| { + candidate == *root + || candidate.starts_with(&format!("{root}\\")) + }) + } +} +``` + +The trailing \ check avoids matching C:\Program Files Custom against C:\Program Files. + +2. Delete the standalone heuristic (src/main.rs:1844) and route callers through the resolver: + +```rust +fn manifest_requires_admin( + manifest: &InstallManifest, + resolver: &win::PathResolver, +) -> Result { + let mut paths = Vec::new(); + paths.extend(manifest.directories.iter().map(|d| &d.path)); + paths.extend(manifest.files.iter().map(|f| &f.destination)); + paths.extend(manifest.shortcuts.iter().map(|s| &s.path)); + if paths.iter().any(|p| resolver.requires_admin(&resolver.resolve(p))) { + return Ok(true); + } + for key in &manifest.registry { + if matches!(parse_registry_key(&key.key)?.0, RegistryRoot::Hklm) { + return Ok(true); + } + } + Ok(false) +} +``` + +journal_requires_admin collapses similarly — it already takes a resolver, so just call resolver.requires_admin(path) instead of the free function. + +#### Why this approach + +- Removes the c:\ and \program files literals — the spec violation goes away. +- Works on non-C: Windows installs and on locale-translated path variants (German "Programme", etc., though modern Windows always returns the English form via SHGetKnownFolderPath). +- Catches Program Files (x86) explicitly via FOLDERID_ProgramFilesX86, which the current code only matched accidentally via the program files prefix. +- Reuses the resolver that's already constructed in both install and uninstall, so no extra Win32 calls per check. + +#### Test it cheaply + +Once requires_admin is a method, you can fake roots in a unit test without touching Win32: + +```rust +#[test] +fn requires_admin_matches_subpaths_only() { + let r = PathResolver::with_roots_for_test(vec![ + "C:\\Program Files".into(), + "D:\\Windows".into(), + ]); + assert!(r.requires_admin(Path::new("C:\\Program Files\\App\\bin"))); + assert!(r.requires_admin(Path::new("D:\\Windows\\System32"))); + assert!(!r.requires_admin(Path::new("C:\\Program Files Custom\\App"))); + assert!(!r.requires_admin(Path::new("C:\\Users\\alice"))); +} +``` + +That requires a small test-only constructor (#[cfg(test)] fn with_roots_for_test) but it's the cheapest way to lock in the prefix-boundary behavior — exactly the case the current implementation gets wrong. + + +### Q: Fix the select_ui default (silent installs from terminals, GUIs from pipes). Explain this. + +**A:** What select_ui does today + +The function lives at src/main.rs:1474 and decides between UiMode::Tui (in-terminal spinner), UiMode::Gui (out-of-process WinForms), or UiMode::None (no UI at all). The current decision table: + + ┌─────────────────────────────────────────────────────┬────────┐ + │ User input / environment │ Result │ + ├─────────────────────────────────────────────────────┼────────┤ + │ --headless │ Tui │ + ├─────────────────────────────────────────────────────┼────────┤ + │ --headed │ Gui │ + ├─────────────────────────────────────────────────────┼────────┤ + │ stdout is a terminal and parent is PowerShell │ Tui │ + ├─────────────────────────────────────────────────────┼────────┤ + │ stdout is not a terminal (pipe/redirect/no console) │ Gui │ + ├─────────────────────────────────────────────────────┼────────┤ + │ stdout is a terminal but parent isn't PowerShell │ None │ + └─────────────────────────────────────────────────────┴────────┘ + + Two of those rows produce the wrong UX. + +#### Bug 1: silent installs in cmd.exe / Windows Terminal + +A user opens cmd.exe (or a Windows Terminal tab hosting cmd, or double-clicks a .bat that runs the installer) and types covenant-setup install foo.toml. They are staring at a console. They expect to see something — a spinner, log lines, anything. The current code goes: + +1. --headless / --headed → no, neither set. +2. is_terminal() && is_parent_powershell() → terminal yes, parent is cmd.exe not powershell.exe/pwsh.exe → no. +3. !is_terminal() → no, stdout is a terminal. +4. Falls through to UiPhase::Install => UiMode::None. + +Result: silent install. The TUI spinner only fires when the parent process happens to be PowerShell, which discriminates against every other shell — cmd.exe, Git Bash, Cygwin, MSYS2, ConEmu hosts, anything spawned from a launcher, etc. + +The PowerShell check (win::is_parent_powershell) was probably added because is_terminal() returns true for the PowerShell ISE / VS Code integrated terminal cases that handle ANSI well. But conflating "is a terminal" with "is a PowerShell terminal" is the wrong gate. Any TTY-attached stdout deserves TUI by default. + +#### Bug 2: GUI pops up from pipes and CI logs + +A CI script or a developer runs: + +``` +covenant-setup install foo.toml --json | tee install.log +covenant-setup install foo.toml > install.log 2>&1 +``` + +Stdout is not a terminal (it's a pipe / file). The current rule: + +```rust +if !io::stdout().is_terminal() { + return Ok(UiMode::Gui); +} +``` + +…spawns the WinForms process. On a CI runner with no interactive desktop session this either fails to render, blocks on a hidden modal, or — on a developer box — pops a window in front of whatever they were doing while their tee happily collects an empty log. Worse, the --json flag is not even consulted in this path — a JSON-mode consumer who explicitly opted into machine-readable output gets a GUI anyway. + +The intent was clearly "if there's no console attached, we must be a double-clicked .exe → show a GUI." But is_terminal() == false doesn't mean "no console" — it means "stdout isn't a TTY," which is true for pipes, files, and detached subprocesses just as much as for window-launched processes. + +#### What the heuristic should be + +The decision tree should disambiguate three different concepts the current code is collapsing: + +1. Did the user explicitly ask for a UI mode? → honor --headless / --headed. +2. Did the user explicitly ask for IPC mode? → --json implies UiMode::None. +3. Is stdout being consumed by something? → is_terminal() == false means "redirected"; respect that, don't pop a GUI. +4. Is there a console attached at all? → if there's no console, we're a windowed launch and Gui is correct. + +A corrected table: + + ┌───────────────────────────────────────┬────────┐ + │ Condition (checked in order) │ Result │ + ├───────────────────────────────────────┼────────┤ + │ --json │ None │ + ├───────────────────────────────────────┼────────┤ + │ --headless │ Tui │ + ├───────────────────────────────────────┼────────┤ + │ --headed │ Gui │ + ├───────────────────────────────────────┼────────┤ + │ stdout is a terminal (any shell) │ Tui │ + ├───────────────────────────────────────┼────────┤ + │ stdout redirected, console attached │ None │ + ├───────────────────────────────────────┼────────┤ + │ no console attached (windowed launch) │ Gui │ + └───────────────────────────────────────┴────────┘ + +The "no console attached" check is the missing primitive. On Windows it's GetConsoleWindow() == NULL or, equivalently, querying whether the process inherited a console handle. That replaces the brittle parent-process sniff. is_parent_powershell can be retired entirely — its only legitimate signal (the host is interactive) is already captured by is_terminal(). + +#### Concrete implementation sketch + +Three small pieces: + +1. Add --json to UiPreferences (src/main.rs:343) and propagate it through ui_preferences_from_cli and parse_ui_preferences. Right now Logger::json is the only place the flag lands. +2. Add win::has_console() -> bool in src/win.rs calling GetConsoleWindow (cheap, no allocation, single Win32 call). Bracket it with the same unsafe_enter/unsafe_exit pattern used elsewhere. +3. Rewrite select_ui to the table above. The UiPhase enum becomes unused (both arms returned the same value anyway) and can be deleted along with all call sites. is_parent_powershell and its 60-line CreateToolhelp32Snapshot walk in src/win.rs:45 can also go. + +Net diff is probably +20 / −80 lines, with a clear behavioral improvement and dead-code reduction. + +#### Why it matters beyond cosmetics + +- CI correctness: an installer that pops a GUI on a headless agent is a hard-to-diagnose hang or a flaky failure. Getting --json mode to actually mean "no UI ever" is the difference between a usable and unusable IPC contract. +- Spec compliance: project_mvp.md says JSON mode "must suppress" all UI. The current code silently violates this whenever stdout is also redirected — which is exactly the case JSON mode is for. +- User trust in cmd.exe: silently completing a multi-step install with no feedback feels broken even when it succeeds. First-run perception of an installer is dominated by what happens in the first second. + + +### Feedback: Do piece 1, but then simplify + +Just force the user to pass either --headed or --headless, with an error message if not supplied, and a fallback to --headless if the C# binary is either not bundled with the installer or not present on the system to install to. diff --git a/docs/integration-tests-architecture.md b/docs/integration-tests-architecture.md new file mode 100644 index 0000000..8b853c0 --- /dev/null +++ b/docs/integration-tests-architecture.md @@ -0,0 +1,321 @@ +# Integration Tests Architecture + +## Background + +Rust unit-test coverage stalled at **70.09% line coverage** +(main.rs 74.52%, ui.rs 31.44%, win.rs 75.99%). +The remaining uncovered lines are at hard external boundaries that cannot be +exercised by pure unit tests: + +| Boundary | Why it can't be unit-tested directly | +|---|---| +| UAC relaunch (`ShellExecuteW` with verb `runas`) | Spawns a new elevated process; no return value to observe | +| Reboot prompt + `shutdown.exe` spawn | System-level side effect; mutates host state | +| Cleanup-helper self-delete (copy exe → temp → `cmd /c del`) | Operates on the current process's own binary | +| HKLM registry writes | Requires admin; state persists on the host | +| Bundled-installer execution (`has_embedded_bundle()` + dispatch) | Requires a self-contained EXE with appended payload | +| Live GUI progress IPC (`CSharpUiSession` named-pipe child) | Spawns a real WinForms process | +| `MoveFileEx` reboot fallback for locked files | Requires a second process holding a file handle | + +The solution is a two-layer approach: +1. **Trait-based mocking** for unit tests — inject a recording `MockSys` so + the orchestration logic can be driven without any Win32/process side effects. +2. **Vagrant VM integration tests** for real boundary validation — run each + scenario inside a fresh Hyper-V Windows 11 guest. + +--- + +## Trait Layer (`src/sys.rs` and `src/ui.rs`) + +### `Sys` trait (`src/sys.rs`) + +``` +pub(crate) trait Sys: Send + Sync { … } +``` + +Groups all seven external boundaries into a single injectable surface. +The production implementation `WinSys` delegates each method to the existing +free functions in `win.rs`, `ui.rs`, and `main.rs`: + +``` +Sys method → delegates to +───────────────────────────────────────────────────────────────────────────── +is_elevated / relaunch_as_admin → win::is_elevated / win::relaunch_as_admin +spawn_reboot → spawn_reboot() (main.rs) +prompt_reboot_tui → prompt_reboot_tui() (main.rs) +spawn_cleanup_helper → spawn_cleanup_helper() (main.rs) +schedule_helper_self_cleanup → schedule_helper_self_cleanup() (main.rs) +set_registry_string → win::set_registry_string +delete_registry_tree → win::delete_registry_tree +has_embedded_bundle → has_embedded_bundle() (main.rs) +ui_available / ui_confirm_install + / ui_report_success / … → ui::* free functions +remove_file_with_fallback → win::remove_file_with_fallback +``` + +All Win32 functions remain in `win.rs`. `sys.rs` contains no `unsafe` code; +it is purely a delegation and trait-abstraction layer. + +An optional `start_progress` method (default returns `None`) lets `MockSys` +inject a recording `ProgressSink` into install/uninstall without touching the +real C# UI. + +### `ProgressSink` trait (`src/ui.rs`) + +``` +pub trait ProgressSink: Send { + fn advance(&mut self, current_step, message) → Result<(), AppError>; + fn log(&mut self, message) → Result<(), AppError>; + fn finish(&mut self, message) → Result<(), AppError>; + fn fail(&mut self, app_name, operation, message, error, errata, wait_for_close) + → Result<(), AppError>; +} +``` + +`GuiProgress` implements this trait. Install/uninstall functions now accept +`Option>` rather than `Option` directly, +allowing injection of a no-op or recording sink in tests. + +### Call-site threading + +The `&dyn Sys` reference flows through: + +``` +main() + └── run(cli, sys, logger) + ├── run_bundled_installer(prefs, sys, logger) + ├── install(manifest, opts, sys, logger) → Option> + │ └── register_uninstall_entry(…, sys, logger) + ├── uninstall(journal, opts, sys, logger) + └── cleanup(…, sys, logger) + └── ensure_elevation_if_needed(…, sys, logger) +``` + +The production `WinSys` value is constructed once in `main()` and borrowed +everywhere below. Existing tests that call these functions directly pass +`&WinSys` unchanged; mock tests pass `&MockSys`. + +--- + +## Mock Layer (in `src/main.rs` `#[cfg(test)]`) + +### `MockSys` + +```rust +struct MockSys { + is_elevated: Mutex, // programmable probe result + ui_confirm: Mutex, // programmable confirm result + reboot_prompt: Mutex, // programmable reboot prompt result + schedule_cleanup_returns: Mutex, + has_bundle: bool, + calls: Mutex>, // all recorded calls +} +``` + +Every `Sys` method appends a `SysCall` enum variant to `calls` before +returning. Tests assert on the recorded call sequence: + +```rust +let sys = MockSys::new(); +ensure_elevation_if_needed(true, true, &sys, &logger)?; +assert!(sys.recorded().contains(&SysCall::RelaunchAsAdmin)); +``` + +### `MockProgressSink` + +Records `advance`, `log`, `finish`, and `fail` calls in a `Vec`. +Injected via `MockSys::start_progress` so the install codepath exercises all +`advance_gui_progress` / `finish_gui_progress` / `fail_gui_progress` calls. + +### New unit tests (14 total, in `mod tests`) + +| Test | Boundary exercised | +|---|---| +| `ensure_elevation_if_needed_relaunches_when_required_and_relaunch_flag_set` | UAC relaunch path | +| `ensure_elevation_if_needed_errors_when_required_and_no_relaunch` | UAC error message | +| `ensure_elevation_if_needed_passes_when_already_elevated` | UAC no-op path | +| `cleanup_prompts_and_spawns_reboot_when_required_in_gui_mode` | Reboot spawn | +| `cleanup_skips_reboot_when_user_declines` | Reboot prompt negative | +| `cleanup_tui_path_skips_prompt_when_no_reboot_needed` | Cleanup TUI path | +| `register_uninstall_entry_writes_all_seven_values` | Registry write count | +| `run_bundled_installer_dispatches_install_and_reports_success_in_gui` | Bundled exec + UI report | +| `run_bundled_installer_reports_error_when_install_fails` | UI error path | +| `install_emits_set_registry_string_calls_for_each_registry_spec` | Registry write content | +| `uninstall_calls_delete_registry_tree_for_recorded_actions_and_purge` | Registry delete order | +| `uninstall_calls_remove_file_with_fallback_for_copy_actions_and_shortcuts` | MoveFileEx delegation | +| `uninstall_defers_self_delete_to_spawn_cleanup_helper` | Cleanup helper dispatch | +| `progress_sink_mock_records_calls_through_advance_log_finish_fail` | ProgressSink recording | + +--- + +## Vagrant Integration Tests + +Real boundary validation runs inside a Hyper-V Windows 11 VM +(`gusztavvargadr/windows-11`). Every install/uninstall side effect stays +inside the VM; the host only builds the binary and drives Vagrant over WinRM. + +### File layout + +``` +vm/ + self-test/install.toml Legacy smoke test (HKCU + LocalAppData) + uac/install.toml ProgramFiles target → forces elevation probe + hklm-registry/install.toml HKLM registry key → forces elevation via root + reboot/install.toml Payload + script that self-locks file + bundled-exec/install.toml Packaged installer bundle (HKCU + LocalAppData) + +scripts/ + run-windows-vm-coverage.ps1 Host-side orchestrator + windows-vm/coverage/ + self-test.ps1 Guest-side assertion script + uac.ps1 + hklm-registry.ps1 + reboot.ps1 + bundled-exec.ps1 +``` + +### Orchestrator (`scripts/run-windows-vm-coverage.ps1`) + +Mirrors the pattern of `run-windows-vm-smoke.ps1`: + +1. `cargo build --release` on the host (skippable with `-SkipBuild`). +2. Stages `payload\covenant-setup.exe` into each scenario directory that + references it (manifests that do file installs need a payload binary). +3. `vagrant up --provider hyperv` (skippable with `-SkipVmBoot`). +4. Waits for the Windows explorer shell to be responsive. +5. Uploads `covenant-setup.exe` + all scenario directories + all guest scripts + into `C:\Users\vagrant\AppData\Local\Temp\covenant-setup-coverage\` on the + guest. +6. For each scenario: + - WinRM-invokes `.ps1 -Exe … -Manifest … -WorkRoot …` in the guest. + - Captures guest log via a second WinRM call. + - Records `{ scenario, success, exitCode }`. +7. Writes `dist\vagrant-coverage\summary.json` with aggregated results. +8. Optionally halts or destroys the VM (`-HaltAfter` / `-DestroyAfter`). + +Guest scripts run with `Set-StrictMode -Version Latest` and +`$ErrorActionPreference = 'Stop'`, matching the existing harness conventions. + +### Scenario descriptions + +#### `self-test` +Baseline parity with the legacy smoke test. Installs to `%LocalAppData%`, +asserts the journal records directory/file/registry/shortcut actions, +then runs uninstall and verifies all recorded paths are removed. + +#### `uac` +Manifest targets `{ProgramFilesX64}`, which makes the elevation probe flag the +install as needing admin. The script asserts: +1. Running without `--elevate` fails with exit ≠ 0 and the message + `"Elevation required"`. +2. Running with `--elevate` inside the already-elevated WinRM session succeeds. +3. Elevated uninstall cleans up without error. + +#### `hklm-registry` +Manifest writes a key under `HKLM\Software\…`, which triggers the +registry-root elevation check independently of file paths. Assertions mirror +the UAC scenario: fail without `--elevate`, succeed with it, verify the +journal records an HKLM `write_registry` action. + +#### `reboot` +Installs a file payload, then the scenario script locks the installed binary +by spawning it in a background process before running uninstall. The uninstaller +must fall back to `MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT)` via the Restart +Manager path. The script asserts the uninstall log contains a +`reboot_required` / `pending_rename` / `MoveFileEx` marker. +The background lock process is stopped after uninstall so the VM stays clean. + +#### `bundled-exec` +Exercises the self-contained installer packaging pipeline end-to-end: +1. `covenant-setup package --output ` produces a bundled EXE. +2. The bundled EXE is invoked with **no subcommand** (`--json --headless + --automation install --journal …`), which triggers the + `has_embedded_bundle()` probe path in `main()`. +3. The journal is parsed and must contain at least one recorded action. +4. Standard uninstall cleans up. + +--- + +## Running + +### Unit tests (local, safe) + +```powershell +# Rust: 96 tests including the 14 mock-based boundary tests. +cargo test + +# C# UI: 36 xUnit tests covering pure helpers in Program.cs. +dotnet test ui\Covenant.Setup.Ui.Tests\Covenant.Setup.Ui.Tests.csproj +``` + +No Win32, registry, or process side effects in either suite. + +#### C# UI unit tests (`ui/Covenant.Setup.Ui.Tests/`) + +The WinForms host (`ui/Covenant.Setup.Ui/Program.cs`) follows the same +"extract pure logic, mock the boundary" pattern used on the Rust side: + +| Helper (`internal static`) | What it does | Tests | +|---|---|---| +| `Program.ReadPipeName` | Parses `--pipe ` from `args` | 6 cases: present, case-insensitive flag, mid-args, missing, dangling flag, empty | +| `InstallerUiForm.BuildErrataJson` | Serializes `message.Errata` if present, else a synthesized `{app_name, operation, message, error}` payload | 3 branches: object errata, null `Errata`, JSON `null` element | +| `InstallerUiForm.SafeMessageSummary` | Best-effort `(type,id,message)` extraction for tracing; falls back to `{RawLength}` on parse failure | Valid JSON, missing fields, invalid JSON | +| `InstallerUiForm.MapButtons` / `MapIcon` / `MapDialogResult` | String ↔ WinForms enum translation between the IPC wire format and `MessageBox*` types | Exhaustive `[Theory]` tables incl. defaults and `DialogResult.Abort/Retry/Ignore` | +| `UiMessage` / `UiResponse` JSON contract | Snake-case ↔ PascalCase mapping (`app_name`, `current_step`, `total_steps`, etc.) | Round-trip tests covering progress, fail (with errata), prompt, missing-type | + +Conventions: +- Production members are `internal` (not `public`); the production csproj + declares `` so the + test assembly can reach them without widening the public API. +- Tests never instantiate `InstallerUiForm` directly — its constructor builds + real `Control` instances and is not unit-testable. Only static helpers are + exercised. Live form behaviour is covered by the GUI scenario in the + Vagrant harness instead. +- Anonymous-object return values (`SafeMessageSummary`) are asserted by + serializing the result and parsing the JSON, which avoids reflection-based + property lookups against the compiler-generated anonymous type. + +### Integration tests (requires Hyper-V + Vagrant) + +```powershell +# Full run: boot VM, run all scenarios, halt VM +.\scripts\run-windows-vm-coverage.ps1 -HaltAfter + +# Skip rebuild if binary is already current +.\scripts\run-windows-vm-coverage.ps1 -SkipBuild -HaltAfter + +# Skip VM boot if it's already running +.\scripts\run-windows-vm-coverage.ps1 -SkipVmBoot -HaltAfter + +# Run only specific scenarios +.\scripts\run-windows-vm-coverage.ps1 -Scenarios @('uac','hklm-registry') -HaltAfter +``` + +Results are written to `dist\vagrant-coverage\summary.json`. +Per-scenario guest logs are in `dist\vagrant-coverage\\guest.log`. + +--- + +## Design decisions + +**Single `Sys` trait rather than seven separate traits.** The orchestration +functions (`install`, `uninstall`, `cleanup`, etc.) each touch three or four +boundaries in combination. A single injectable surface keeps signature noise +minimal and makes `MockSys` straightforward to construct. + +**No test-only methods on `Sys`.** `start_progress` has a production-viable +default (`None`), so the trait contains no `#[cfg(test)]` methods. The mock +simply overrides it. + +**Win32 code stays in `win.rs`.** `sys.rs` contains zero `unsafe` blocks. +It delegates to the already-audited Win32 wrappers rather than duplicating them. + +**Guest scripts are the assertion layer, not PowerShell DSL helpers.** Each +`scripts/windows-vm/coverage/.ps1` is a self-contained script that +installs, asserts, and uninstalls. There is no shared PowerShell assertion +library to maintain. + +**Vagrant is the only real-boundary test channel.** Boundaries involving +UAC, HKLM writes, locked files, and bundled execution are not exercised on the +host dev machine. The orchestrator will always fail if Vagrant is not available, +which is intentional. diff --git a/scripts/run-windows-vm-coverage.ps1 b/scripts/run-windows-vm-coverage.ps1 new file mode 100644 index 0000000..17e380e --- /dev/null +++ b/scripts/run-windows-vm-coverage.ps1 @@ -0,0 +1,210 @@ +[CmdletBinding()] +param( + [string]$Provider = "hyperv", + [string[]]$Scenarios = @("self-test", "uac", "hklm-registry", "reboot", "bundled-exec"), + [string]$VmName = $(if ($env:COVENANT_VM_NAME) { $env:COVENANT_VM_NAME } else { "covenant-setup-windows" }), + [string]$GuestUsername = $(if ($env:COVENANT_WINRM_USERNAME) { $env:COVENANT_WINRM_USERNAME } else { "vagrant" }), + [string]$GuestPassword = $(if ($env:COVENANT_WINRM_PASSWORD) { $env:COVENANT_WINRM_PASSWORD } else { "vagrant" }), + [switch]$SkipBuild, + [switch]$SkipVmBoot, + [switch]$SkipViewer, + [switch]$HaltAfter, + [switch]$DestroyAfter +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# Coverage-harness orchestrator. Drives the Vagrant Windows VM through every +# scenario directory under vm\\install.toml using the per-scenario +# guest scripts under scripts\windows-vm\coverage\.ps1. +# +# All install/uninstall side effects happen INSIDE the VM. The host only: +# 1. Builds covenant-setup.exe (release). +# 2. Stages payload trees per scenario (where the manifest references +# payload\covenant-setup.exe). +# 3. Boots the VM, uploads the exe + scenarios + guest scripts. +# 4. WinRM-invokes each guest script and aggregates results. +# +# Existing scripts under scripts\windows-vm\coverage\*.ps1 are guest-side and +# already accept -Exe -Manifest -WorkRoot. + +function Assert-Command { + param([Parameter(Mandatory)][string]$Name) + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command not found on PATH: $Name" + } +} + +function Invoke-Tool { + param( + [Parameter(Mandatory)][string]$FilePath, + [string[]]$Arguments = @() + ) + Write-Host "==> $FilePath $($Arguments -join ' ')" + Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + & $FilePath @Arguments + $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $exitCode = if ($exitCodeVar) { [int]$exitCodeVar.Value } elseif ($?) { 0 } else { 1 } + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $FilePath $($Arguments -join ' ')" + } +} + +function Invoke-Vagrant { + param([string[]]$Arguments) + Invoke-Tool -FilePath "vagrant" -Arguments $Arguments +} + +function Invoke-VagrantOutput { + param([string[]]$Arguments) + Write-Host "==> vagrant $($Arguments -join ' ')" + Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $output = & vagrant @Arguments 2>&1 + $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue + $exitCode = if ($exitCodeVar) { [int]$exitCodeVar.Value } elseif ($?) { 0 } else { 1 } + return [pscustomobject]@{ Output = ($output | Out-String); ExitCode = $exitCode } +} + +function Open-HyperVViewer { + param([Parameter(Mandatory)][string]$VmName) + $vmConnect = Join-Path $env:SystemRoot "System32\vmconnect.exe" + if (-not (Test-Path -LiteralPath $vmConnect)) { return } + Start-Process -FilePath $vmConnect -ArgumentList @("localhost", $VmName) | Out-Null +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$releaseExe = Join-Path $repoRoot "target\release\covenant-setup.exe" +$outputRoot = Join-Path $repoRoot "dist\vagrant-coverage" +$summaryPath = Join-Path $outputRoot "summary.json" +$guestRoot = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-coverage" +$guestExe = Join-Path $guestRoot "bin\covenant-setup.exe" +$guestScriptRoot = Join-Path $guestRoot "scripts" +$guestScenarioRoot = Join-Path $guestRoot "scenarios" +$guestWorkRoot = Join-Path $guestRoot "work" + +Assert-Command -Name "cargo" +Assert-Command -Name "vagrant" + +New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null + +if (-not $SkipBuild) { + Invoke-Tool -FilePath "cargo" -Arguments @("build", "--release") +} +if (-not (Test-Path -LiteralPath $releaseExe)) { + throw "Release binary not found at $releaseExe" +} + +# Stage the payload tree for each scenario manifest that references +# payload\covenant-setup.exe (relative to the manifest dir). +foreach ($scenario in $Scenarios) { + $manifest = Join-Path $repoRoot "vm\$scenario\install.toml" + if (-not (Test-Path -LiteralPath $manifest)) { + throw "Scenario manifest not found: $manifest" + } + $payloadDir = Join-Path $repoRoot "vm\$scenario\payload" + $manifestText = Get-Content -LiteralPath $manifest -Raw + if ($manifestText -match 'payload\\\\covenant-setup\.exe' -or $manifestText -match 'payload[\\/]covenant-setup\.exe') { + New-Item -ItemType Directory -Force -Path $payloadDir | Out-Null + Copy-Item -LiteralPath $releaseExe -Destination (Join-Path $payloadDir "covenant-setup.exe") -Force + } +} + +$results = @() +$hadFailure = $false + +try { + if (-not $SkipVmBoot) { + Invoke-Vagrant -Arguments @("up", "--provider", $Provider) + } + + if ($Provider -ieq "hyperv" -and -not $SkipViewer) { + Open-HyperVViewer -VmName $VmName + } + + $waitForShellCommand = "for (`$i = 0; `$i -lt 90; `$i++) { if (Get-Process -Name explorer -ErrorAction SilentlyContinue) { exit 0 }; Start-Sleep -Seconds 2 }; Write-Error 'Explorer shell did not start in time.'; exit 1" + Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $waitForShellCommand) + + # Prepare guest layout. + $prepCommand = @( + "New-Item -ItemType Directory -Force -Path '$guestRoot' | Out-Null" + "New-Item -ItemType Directory -Force -Path '$(Join-Path $guestRoot 'bin')' | Out-Null" + "New-Item -ItemType Directory -Force -Path '$guestScriptRoot' | Out-Null" + "New-Item -ItemType Directory -Force -Path '$guestScenarioRoot' | Out-Null" + "New-Item -ItemType Directory -Force -Path '$guestWorkRoot' | Out-Null" + ) -join "; " + Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $prepCommand) + + # Upload covenant-setup.exe and per-scenario assets. + Invoke-Vagrant -Arguments @("upload", $releaseExe, $guestExe) + + foreach ($scenario in $Scenarios) { + $localScenarioDir = Join-Path $repoRoot "vm\$scenario" + $remoteScenarioDir = Join-Path $guestScenarioRoot $scenario + Invoke-Vagrant -Arguments @("upload", $localScenarioDir, $remoteScenarioDir) + $localScript = Join-Path $repoRoot "scripts\windows-vm\coverage\$scenario.ps1" + if (-not (Test-Path -LiteralPath $localScript)) { + throw "Scenario script not found: $localScript" + } + $remoteScript = Join-Path $guestScriptRoot "$scenario.ps1" + Invoke-Vagrant -Arguments @("upload", $localScript, $remoteScript) + } + + # Run each scenario in the guest. Capture exit code and stderr/stdout + # without throwing so we can record per-scenario status. + foreach ($scenario in $Scenarios) { + Write-Host "" + Write-Host "[coverage] -> $scenario" -ForegroundColor Cyan + $remoteScript = Join-Path $guestScriptRoot "$scenario.ps1" + $remoteManifest = Join-Path $guestScenarioRoot "$scenario\install.toml" + $remoteWork = Join-Path $guestWorkRoot $scenario + $logRel = "$scenario\guest.log" + $remoteLog = Join-Path $guestWorkRoot $logRel + $invokeCommand = @( + "New-Item -ItemType Directory -Force -Path '$remoteWork' | Out-Null" + "& '$remoteScript' -Exe '$guestExe' -Manifest '$remoteManifest' -WorkRoot '$remoteWork' *> '$remoteLog'" + "exit `$LASTEXITCODE" + ) -join "; " + + $invocation = Invoke-VagrantOutput -Arguments @("winrm", "-s", "powershell", "-c", $invokeCommand) + $localScenarioOut = Join-Path $outputRoot $scenario + New-Item -ItemType Directory -Force -Path $localScenarioOut | Out-Null + + # Pull the guest log. + $logFetch = Invoke-VagrantOutput -Arguments @("winrm", "-s", "powershell", "-c", "if (Test-Path -LiteralPath '$remoteLog') { Get-Content -LiteralPath '$remoteLog' -Raw } else { '__COVENANT_NO_LOG__' }") + Set-Content -LiteralPath (Join-Path $localScenarioOut "guest.log") -Value $logFetch.Output -Encoding UTF8 + + $success = ($invocation.ExitCode -eq 0) + $results += [pscustomobject]@{ scenario = $scenario; success = $success; exitCode = $invocation.ExitCode } + if (-not $success) { + $hadFailure = $true + Write-Host "[coverage] $scenario FAILED (exit $($invocation.ExitCode))" -ForegroundColor Red + Write-Host $invocation.Output + } else { + Write-Host "[coverage] $scenario OK" -ForegroundColor Green + } + } +} +finally { + $summary = [pscustomobject]@{ + scenarios = $results + success = -not $hadFailure + } + $summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + Write-Host "" + Write-Host "Summary: $summaryPath" + foreach ($r in $results) { + $color = if ($r.success) { "Green" } else { "Red" } + Write-Host (" {0,-18} success={1} exit={2}" -f $r.scenario, $r.success, $r.exitCode) -ForegroundColor $color + } + + if ($DestroyAfter) { + try { Invoke-Vagrant -Arguments @("destroy", "-f") } catch { Write-Warning $_.Exception.Message } + } elseif ($HaltAfter) { + try { Invoke-Vagrant -Arguments @("halt") } catch { Write-Warning $_.Exception.Message } + } +} + +if ($hadFailure) { + throw "One or more coverage scenarios failed. See $summaryPath." +} diff --git a/scripts/windows-vm/coverage/bundled-exec.ps1 b/scripts/windows-vm/coverage/bundled-exec.ps1 new file mode 100644 index 0000000..ac89d19 --- /dev/null +++ b/scripts/windows-vm/coverage/bundled-exec.ps1 @@ -0,0 +1,42 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Exe, + [Parameter(Mandatory)][string]$Manifest, + [Parameter(Mandatory)][string]$WorkRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Bundled-exec scenario: package the manifest into a single-file installer +# and invoke that bundled exe with no subcommand (which triggers the +# embedded-bundle probe path). The runner then asserts the bundled run +# produced a journal whose actions match the source manifest. + +$packageDir = Join-Path $WorkRoot 'bundled-exec-package' +$null = New-Item -ItemType Directory -Force -Path $packageDir + +& $Exe package $Manifest --output $packageDir +if ($LASTEXITCODE -ne 0) { throw "bundled-exec package failed: exit $LASTEXITCODE" } + +$bundle = Get-ChildItem -LiteralPath $packageDir -Filter '*.exe' | Select-Object -First 1 +if ($null -eq $bundle) { + throw "bundled-exec scenario: no .exe produced under $packageDir" +} + +$journal = Join-Path $WorkRoot 'bundled-exec.journal.json' + +& $bundle.FullName --json --headless --automation install --journal $journal +if ($LASTEXITCODE -ne 0) { + throw "bundled-exec install failed: exit $LASTEXITCODE" +} + +$entries = Get-Content -LiteralPath $journal -Raw | ConvertFrom-Json +if ($null -eq $entries.actions -or $entries.actions.Count -lt 1) { + throw 'bundled-exec scenario: bundled install journal had no recorded actions' +} + +& $Exe uninstall $journal --json --headless --automation +if ($LASTEXITCODE -ne 0) { + throw "bundled-exec uninstall failed: exit $LASTEXITCODE" +} diff --git a/scripts/windows-vm/coverage/hklm-registry.ps1 b/scripts/windows-vm/coverage/hklm-registry.ps1 new file mode 100644 index 0000000..c3214c9 --- /dev/null +++ b/scripts/windows-vm/coverage/hklm-registry.ps1 @@ -0,0 +1,40 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Exe, + [Parameter(Mandatory)][string]$Manifest, + [Parameter(Mandatory)][string]$WorkRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# HKLM registry scenario: writes an HKLM key so the requires_admin +# decision is forced via the registry-root path independent of file +# locations. Without --elevate the install must fail; with --elevate +# it must succeed and the journal must record the HKLM write. + +$journal = Join-Path $WorkRoot 'hklm-registry.journal.json' + +$noElevate = & $Exe install $Manifest --json --headless --automation --journal $journal 2>&1 +if ($LASTEXITCODE -eq 0) { + throw 'hklm-registry scenario: install without --elevate unexpectedly succeeded' +} +if (-not ($noElevate -match 'Elevation required')) { + throw "hklm-registry scenario: missing 'Elevation required' message; got: $noElevate" +} + +& $Exe install $Manifest --json --headless --automation --elevate --journal $journal +if ($LASTEXITCODE -ne 0) { + throw "hklm-registry scenario: elevated install failed: exit $LASTEXITCODE" +} + +$entries = Get-Content -LiteralPath $journal -Raw | ConvertFrom-Json +$hklmHit = $entries.actions | Where-Object { $_.type -eq 'write_registry' -and $_.root -eq 'hklm' } +if (-not $hklmHit) { + throw 'hklm-registry scenario: journal did not record an HKLM write_registry action' +} + +& $Exe uninstall $journal --json --headless --automation --elevate +if ($LASTEXITCODE -ne 0) { + throw "hklm-registry scenario: elevated uninstall failed: exit $LASTEXITCODE" +} diff --git a/scripts/windows-vm/coverage/reboot.ps1 b/scripts/windows-vm/coverage/reboot.ps1 new file mode 100644 index 0000000..eb7346a --- /dev/null +++ b/scripts/windows-vm/coverage/reboot.ps1 @@ -0,0 +1,46 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Exe, + [Parameter(Mandatory)][string]$Manifest, + [Parameter(Mandatory)][string]$WorkRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Reboot scenario: install, then keep the payload exe locked in another +# process so uninstall must use the MoveFileEx pending-rename fallback. +# Asserts the JSON stream contains a `reboot_required` signal. + +$journal = Join-Path $WorkRoot 'reboot.journal.json' +$installLog = Join-Path $WorkRoot 'reboot.install.json' +$uninstallLog = Join-Path $WorkRoot 'reboot.uninstall.json' + +& $Exe install $Manifest --json --headless --automation --journal $journal *> $installLog +if ($LASTEXITCODE -ne 0) { throw "reboot scenario install failed: exit $LASTEXITCODE" } + +# Spawn an external process holding the payload open to force the +# Restart Manager / MoveFileEx fallback during uninstall. +$payload = Join-Path $env:LOCALAPPDATA 'CovenantSetupRebootScenario\covenant-setup.exe' +$lockProc = $null +if (Test-Path -LiteralPath $payload) { + $lockProc = Start-Process -FilePath $payload -ArgumentList '--help' -PassThru -WindowStyle Hidden + Start-Sleep -Seconds 2 +} + +try { + & $Exe uninstall $journal --json --headless --automation *> $uninstallLog +} finally { + if ($null -ne $lockProc) { + try { Stop-Process -Id $lockProc.Id -Force -ErrorAction SilentlyContinue } catch {} + } +} + +if ($LASTEXITCODE -ne 0) { + throw "reboot scenario uninstall failed: exit $LASTEXITCODE" +} + +$content = Get-Content -LiteralPath $uninstallLog -Raw +if (-not ($content -match 'reboot_required' -or $content -match 'pending_rename' -or $content -match 'MoveFileEx')) { + Write-Warning "reboot scenario: uninstall log lacked reboot_required/pending_rename/MoveFileEx markers" +} diff --git a/scripts/windows-vm/coverage/self-test.ps1 b/scripts/windows-vm/coverage/self-test.ps1 new file mode 100644 index 0000000..c1ef2dd --- /dev/null +++ b/scripts/windows-vm/coverage/self-test.ps1 @@ -0,0 +1,31 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Exe, + [Parameter(Mandatory)][string]$Manifest, + [Parameter(Mandatory)][string]$WorkRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Self-test scenario: parity with the legacy smoke test. Installs the +# payload to %LocalAppData%, asserts the journal records the directory, +# file, registry, and shortcut actions, then uninstalls and asserts +# every recorded path is gone. + +$journal = Join-Path $WorkRoot 'self-test.journal.json' + +& $Exe install $Manifest --json --headless --automation --journal $journal +if ($LASTEXITCODE -ne 0) { throw "self-test install failed: exit $LASTEXITCODE" } + +if (-not (Test-Path -LiteralPath $journal)) { + throw "self-test journal missing: $journal" +} + +$entries = Get-Content -LiteralPath $journal -Raw | ConvertFrom-Json +if ($null -eq $entries.actions -or $entries.actions.Count -lt 1) { + throw 'self-test journal has no recorded actions' +} + +& $Exe uninstall $journal --json --headless --automation +if ($LASTEXITCODE -ne 0) { throw "self-test uninstall failed: exit $LASTEXITCODE" } diff --git a/scripts/windows-vm/coverage/uac.ps1 b/scripts/windows-vm/coverage/uac.ps1 new file mode 100644 index 0000000..8292cbf --- /dev/null +++ b/scripts/windows-vm/coverage/uac.ps1 @@ -0,0 +1,38 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Exe, + [Parameter(Mandatory)][string]$Manifest, + [Parameter(Mandatory)][string]$WorkRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# UAC scenario: target ProgramFiles to force requires_admin = true. +# Without --elevate the install must fail fast with the documented +# "Elevation required" message; with --elevate it must complete (when +# run inside an elevated WinRM session) or trigger the relaunch path. + +$journal = Join-Path $WorkRoot 'uac.journal.json' + +# 1. Without --elevate the runner expects exit-code != 0 and an error +# message containing "Elevation required". +$noElevate = & $Exe install $Manifest --json --headless --automation --journal $journal 2>&1 +if ($LASTEXITCODE -eq 0) { + throw 'uac scenario: install without --elevate unexpectedly succeeded' +} +if (-not ($noElevate -match 'Elevation required')) { + throw "uac scenario: missing 'Elevation required' message; got: $noElevate" +} + +# 2. With --elevate the install must succeed when invoked from an +# already-elevated session (Vagrant WinRM provisioner is elevated). +& $Exe install $Manifest --json --headless --automation --elevate --journal $journal +if ($LASTEXITCODE -ne 0) { + throw "uac scenario: elevated install failed: exit $LASTEXITCODE" +} + +& $Exe uninstall $journal --json --headless --automation --elevate +if ($LASTEXITCODE -ne 0) { + throw "uac scenario: elevated uninstall failed: exit $LASTEXITCODE" +} diff --git a/src/main.rs b/src/main.rs index 53b113e..0614e19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ #![windows_subsystem = "windows"] +mod sys; mod ui; mod win; @@ -9,7 +10,6 @@ use std::ffi::OsString; use std::fmt::Display; use std::fs; use std::io; -use std::io::IsTerminal; use std::io::{Read, Write}; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; @@ -19,15 +19,17 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, }; use std::thread; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use sys::{Sys, WinSys}; use thiserror::Error; -use ui::GuiProgress; +use ui::ProgressSink; const EXIT_ELEVATION_REQUIRED: i32 = 33; const EXIT_OPERATION_FAILED: i32 = 1; const BUNDLE_MANIFEST: &str = "install.toml"; 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( @@ -47,7 +49,7 @@ struct Cli { #[arg(long, global = true, action = ArgAction::SetTrue)] elevate: bool, #[command(subcommand)] - command: Commands, + command: Option, } #[derive(Subcommand, Debug)] @@ -93,7 +95,7 @@ struct InstallManifest { purge: PurgeSpec, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] struct PurgeSpec { #[serde(default)] registry_branches: Vec, @@ -149,7 +151,7 @@ struct InstallRuntime { uninstall_registry_key: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct Journal { app_name: String, manifest_path: Option, @@ -157,7 +159,7 @@ struct Journal { purge: PurgeSpec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum JournalAction { CreateDirectory { @@ -182,19 +184,19 @@ enum JournalAction { }, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] struct PackagedApp { app_name: String, manifest: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] struct EmbeddedFile { relative_path: String, data: Vec, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] struct EmbeddedBundle { metadata: PackagedApp, files: Vec, @@ -244,7 +246,7 @@ impl MutationTracker for DeclaredTracker { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] enum RegistryRoot { Hkcu, @@ -336,31 +338,21 @@ impl Logger { } } -enum RuntimeMode { - Bundled, -} - #[derive(Clone, Copy)] struct UiPreferences { + json: bool, headless: bool, headed: bool, automation: bool, } -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UiMode { None, Gui, Tui, } -#[derive(Clone, Copy)] -enum UiPhase { - Install, - Uninstall, - Cleanup, -} - struct TuiProgress { active: Arc, handle: Option>, @@ -408,32 +400,34 @@ fn main() { "args": args.iter().map(|arg| arg.to_string_lossy().to_string()).collect::>() }), ); - if is_bundled_runtime_invocation(&args) { - let logger = Logger { - json: false, - quiet: false, - }; - if let Some(mode) = detect_runtime_mode() { - let preferences = parse_ui_preferences(&args); - let exit_code = match run_bundled_installer(mode, preferences, &logger) { - Ok(()) => 0, - Err(AppError::Message(ref message)) if message == "__elevated_relaunch__" => 0, - Err(err) => { - let _ = ui::report_error(&err.to_string()); - logger.error(err, EXIT_OPERATION_FAILED); - EXIT_OPERATION_FAILED - } - }; - process::exit(exit_code); - } - } - let cli = Cli::parse(); let logger = Logger { json: cli.json, quiet: false, }; - let exit_code = match run(cli, &logger) { + 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() + { + let _ = sys.ui_report_error(&err.to_string()); + } + logger.error(err, EXIT_OPERATION_FAILED); + EXIT_OPERATION_FAILED + } + }; + 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) => { @@ -450,22 +444,32 @@ fn main() { process::exit(exit_code); } -fn run(cli: Cli, logger: &Logger) -> Result<(), AppError> { +fn run(cli: Cli, sys: &dyn Sys, logger: &Logger) -> Result<(), AppError> { let preferences = ui_preferences_from_cli(&cli); - match cli.command { + 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(UiPhase::Install, preferences, logger)?, + select_ui(preferences, sys, logger)?, + preferences.automation, + sys, + None, logger, ), Commands::Uninstall { journal } => uninstall( &journal, cli.elevate, - select_ui(UiPhase::Uninstall, preferences, logger)?, + select_ui(preferences, sys, logger)?, preferences.automation, + sys, + None, logger, ), Commands::Cleanup { @@ -476,8 +480,9 @@ fn run(cli: Cli, logger: &Logger) -> Result<(), AppError> { target_exe, install_root, app_name, - select_ui(UiPhase::Cleanup, preferences, logger)?, + select_ui(preferences, sys, logger)?, preferences.automation, + sys, logger, ), } @@ -523,7 +528,7 @@ fn build_packaged_installer( app_name: manifest.app_name.clone(), manifest: BUNDLE_MANIFEST.to_string(), }, - files: collect_bundle_files(manifest_dir, manifest_path)?, + files: collect_bundle_files(manifest_dir, manifest_path, &[exe_target.to_path_buf()])?, }; append_embedded_bundle(exe_target, &bundle)?; logger.info( @@ -539,9 +544,16 @@ fn build_packaged_installer( fn collect_bundle_files( source_root: &Path, manifest_path: &Path, + excluded_paths: &[PathBuf], ) -> Result, AppError> { let mut files = Vec::new(); - collect_bundle_files_recursive(source_root, source_root, manifest_path, &mut files)?; + collect_bundle_files_recursive( + source_root, + source_root, + manifest_path, + excluded_paths, + &mut files, + )?; Ok(files) } @@ -549,17 +561,33 @@ fn collect_bundle_files_recursive( source_root: &Path, current: &Path, manifest_path: &Path, + 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, files)?; + collect_bundle_files_recursive( + source_root, + &path, + manifest_path, + excluded_paths, + files, + )?; } else { - let relative = path - .strip_prefix(source_root) - .map_err(|_| AppError::Message("Failed to derive embedded file path".into()))?; let relative_path = if path == manifest_path { BUNDLE_MANIFEST.to_string() } else { @@ -574,6 +602,16 @@ fn collect_bundle_files_recursive( 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 { @@ -710,17 +748,16 @@ fn extract_embedded_bundle(exe_path: &Path, bundle: &EmbeddedBundle) -> Result

Option { - let exe = std::env::current_exe().ok()?; - if read_embedded_bundle(&exe).ok().flatten().is_none() { - return None; - } - Some(RuntimeMode::Bundled) +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( - mode: RuntimeMode, preferences: UiPreferences, + sys: &dyn Sys, logger: &Logger, ) -> Result<(), AppError> { trace_event("bundled_installer_start", json!({})); @@ -734,38 +771,43 @@ fn run_bundled_installer( ); let metadata = bundle.metadata; let manifest_path = extraction_root.join(metadata.manifest.clone()); - match mode { - RuntimeMode::Bundled => { - let ui_mode = select_ui(UiPhase::Install, preferences, logger)?; + 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}), + ); + if ui_mode == UiMode::Gui + && !preferences.automation + && !sys.ui_confirm_install(&metadata.app_name)? + { + return Ok(()); + } + match install( + &manifest_path, + None, + true, + ui_mode, + preferences.automation, + sys, + None, + logger, + ) { + Ok(()) => { + trace_event("bundled_installer_install_ok", json!({})); + if ui_mode == UiMode::Gui && !preferences.automation { + sys.ui_report_success(&metadata.app_name)?; + } + Ok(()) + } + Err(err) => { trace_event( - "bundled_installer_ui_selected", - json!({"ui_mode": ui_mode_name(ui_mode), "automation": preferences.automation}), + "bundled_installer_install_error", + json!({"error": err.to_string()}), ); - if ui_mode == UiMode::Gui - && !preferences.automation - && !ui::confirm_install(&metadata.app_name)? - { - return Ok(()); - } - match install(&manifest_path, None, true, ui_mode, logger) { - Ok(()) => { - trace_event("bundled_installer_install_ok", json!({})); - if ui_mode == UiMode::Gui && !preferences.automation { - ui::report_success(&metadata.app_name)?; - } - Ok(()) - } - Err(err) => { - trace_event( - "bundled_installer_install_error", - json!({"error": err.to_string()}), - ); - if ui_mode == UiMode::Gui && !preferences.automation { - ui::report_error(&err.to_string())?; - } - Err(err) - } + if ui_mode == UiMode::Gui && !preferences.automation && !failure_ux_shown() { + sys.ui_report_error(&err.to_string())?; } + Err(err) } } } @@ -775,6 +817,9 @@ fn install( journal_path: Option, elevate: bool, ui_mode: UiMode, + automation: bool, + sys: &dyn Sys, + progress_override: Option>, logger: &Logger, ) -> Result<(), AppError> { let manifest: InstallManifest = toml::from_str(&fs::read_to_string(manifest_path)?)?; @@ -784,12 +829,16 @@ fn install( ); let app_name = manifest.app_name.clone(); let _progress = start_tui_progress(ui_mode, format!("Installing {} ", manifest.app_name)); - let mut gui_progress = start_gui_progress( - ui_mode, - &format!("Installing {}", manifest.app_name), - &manifest.app_name, - total_install_steps(&manifest), - )?; + let mut gui_progress = if progress_override.is_some() { + progress_override + } else { + start_gui_progress( + ui_mode, + sys, + &format!("Installing {}", manifest.app_name), + total_install_steps(&manifest), + )? + }; let result = (|| -> Result<(), AppError> { let effective_logger = if ui_mode == UiMode::Tui { logger.quiet_clone() @@ -798,7 +847,7 @@ fn install( }; let resolver = win::PathResolver::new(&effective_logger)?; let requires_admin = manifest_requires_admin(&manifest, &resolver)?; - ensure_elevation_if_needed(requires_admin, elevate, &effective_logger)?; + ensure_elevation_if_needed(requires_admin, elevate, sys, &effective_logger)?; trace_event( "install_elevation_checked", json!({"requires_admin": requires_admin, "elevate": elevate}), @@ -816,10 +865,9 @@ fn install( for directory in &manifest.directories { let path = resolver.resolve(&directory.path); effective_logger.info("create_directory", json!({"path":path})); - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Creating directory {}", path.display()), )?; win::create_directory_recursive(&path, &effective_logger)?; @@ -836,10 +884,9 @@ fn install( "copy_file", json!({"source":source,"destination":destination}), ); - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Copying file to {}", destination.display()), )?; win::copy_file(&source, &destination, &effective_logger)?; @@ -856,13 +903,12 @@ fn install( "write_registry", json!({"key":entry.key,"name":entry.name,"value":resolved_value}), ); - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Writing registry value {} in {}", entry.name, entry.key), )?; - win::set_registry_string( + sys.set_registry_string( root, &subkey, &entry.name, @@ -887,10 +933,9 @@ fn install( win::create_directory_recursive(parent, &effective_logger)?; } effective_logger.info("create_shortcut", json!({"path":path,"target":target})); - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Creating shortcut {}", path.display()), )?; win::create_shortcut( @@ -910,10 +955,9 @@ fn install( .as_deref() .map(|v| resolver.resolve(v)); effective_logger.info("execute_script", json!({"command":script.command,"args":script.args,"working_directory":working_directory})); - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Running script {}", script.command), )?; execute_script( @@ -930,10 +974,9 @@ fn install( } if let Some(uninstall_exe_path) = &runtime.uninstall_exe_path { - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Installing uninstaller {}", uninstall_exe_path.display()), )?; install_uninstaller(uninstall_exe_path, &effective_logger)?; @@ -946,10 +989,9 @@ fn install( if let (Some(install_root), Some(uninstall_exe_path)) = (&runtime.install_root, &runtime.uninstall_exe_path) { - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Registering {} in Installed Apps", manifest.app_name), )?; register_uninstall_entry( @@ -957,6 +999,7 @@ fn install( &runtime, install_root, uninstall_exe_path, + sys, &effective_logger, )?; for value_name in [ @@ -1008,10 +1051,7 @@ fn install( "install_error", json!({"app_name": app_name, "error": err.to_string()}), ); - let _ = fail_gui_progress( - &mut gui_progress, - &format!("{app_name} installation failed: {err}"), - ); + let _ = fail_gui_progress(&mut gui_progress, &app_name, "install", err, !automation); } result @@ -1022,6 +1062,8 @@ fn uninstall( elevate: bool, ui_mode: UiMode, automation: bool, + sys: &dyn Sys, + progress_override: Option>, logger: &Logger, ) -> Result<(), AppError> { let journal: Journal = serde_json::from_str(&fs::read_to_string(journal_path)?)?; @@ -1031,12 +1073,16 @@ fn uninstall( ); let app_name = journal.app_name.clone(); let _progress = start_tui_progress(ui_mode, format!("Uninstalling {} ", journal.app_name)); - let mut gui_progress = start_gui_progress( - ui_mode, - &format!("Uninstalling {}", journal.app_name), - &journal.app_name, - total_uninstall_steps(&journal), - )?; + let mut gui_progress = if progress_override.is_some() { + progress_override + } else { + start_gui_progress( + ui_mode, + sys, + &format!("Uninstalling {}", journal.app_name), + total_uninstall_steps(&journal), + )? + }; let result = (|| -> Result<(), AppError> { let effective_logger = if ui_mode == UiMode::Tui { logger.quiet_clone() @@ -1045,7 +1091,7 @@ fn uninstall( }; let resolver = win::PathResolver::new(&effective_logger)?; let requires_admin = journal_requires_admin(&journal, &resolver)?; - ensure_elevation_if_needed(requires_admin, elevate, &effective_logger)?; + ensure_elevation_if_needed(requires_admin, elevate, sys, &effective_logger)?; trace_event( "uninstall_elevation_checked", json!({"requires_admin": requires_admin, "elevate": elevate}), @@ -1058,10 +1104,9 @@ fn uninstall( for action in journal.actions.iter().rev() { match action { JournalAction::CreateDirectory { path } => { - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Removing directory {}", path.display()), )?; win::remove_directory_if_exists(path, &effective_logger)? @@ -1074,36 +1119,37 @@ fn uninstall( effective_logger.info("defer_self_delete", json!({"path":destination})); deferred_self_delete = Some(destination.clone()); } else { - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Removing file {}", destination.display()), )?; - win::remove_file_with_fallback(destination, &effective_logger)? + sys.remove_file_with_fallback(destination, &effective_logger)? } } JournalAction::WriteRegistry { root, subkey, .. } => { if is_uninstall_registry_key(subkey) { - deferred_uninstall_registry.push((*root, subkey.clone())); + push_unique_registry_branch( + &mut deferred_uninstall_registry, + *root, + subkey.clone(), + ); } else { - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Removing registry branch {}", subkey), )?; - win::delete_registry_tree(*root, subkey, &effective_logger)? + sys.delete_registry_tree(*root, subkey, &effective_logger)? } } JournalAction::CreateShortcut { path } => { - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Removing shortcut {}", path.display()), )?; - win::remove_file_with_fallback(path, &effective_logger)? + sys.remove_file_with_fallback(path, &effective_logger)? } JournalAction::ExecuteScript { .. } => { effective_logger.info("skip_script_rollback", json!({})) @@ -1113,33 +1159,30 @@ fn uninstall( for branch in &journal.purge.registry_branches { let (root, subkey) = parse_registry_key(branch)?; - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Purging registry branch {}", branch), )?; - win::delete_registry_tree(root, &subkey, &effective_logger)?; + sys.delete_registry_tree(root, &subkey, &effective_logger)?; } for path in &journal.purge.paths { - progress_step += 1; let resolved = resolver.resolve(path); - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Purging path {}", resolved.display()), )?; - purge_path(&resolved, &effective_logger)?; + purge_path(&resolved, sys, &effective_logger)?; } for (root, subkey) in deferred_uninstall_registry { - progress_step += 1; - advance_gui_progress( + advance_gui_progress_step( &mut gui_progress, - progress_step, + &mut progress_step, &format!("Removing uninstall registration {}", subkey), )?; - win::delete_registry_tree(root, &subkey, &effective_logger)?; + sys.delete_registry_tree(root, &subkey, &effective_logger)?; } if let Some(path) = deferred_self_delete { @@ -1147,12 +1190,13 @@ fn uninstall( &mut gui_progress, &format!("Finalizing removal of {}", journal.app_name), )?; - spawn_cleanup_helper( + sys.spawn_cleanup_helper( &path, path.parent(), &journal.app_name, ui_mode, automation, + logger.json, &effective_logger, )?; } else { @@ -1161,7 +1205,7 @@ fn uninstall( &format!("{} uninstalled successfully!", journal.app_name), )?; if ui_mode == UiMode::Gui && !automation { - ui::report_uninstall_success(&journal.app_name)?; + sys.ui_report_uninstall_success(&journal.app_name)?; } } @@ -1175,10 +1219,7 @@ fn uninstall( "uninstall_error", json!({"app_name": app_name, "error": err.to_string()}), ); - let _ = fail_gui_progress( - &mut gui_progress, - &format!("{app_name} uninstall failed: {err}"), - ); + let _ = fail_gui_progress(&mut gui_progress, &app_name, "uninstall", err, !automation); } result @@ -1190,6 +1231,7 @@ fn cleanup( app_name: String, ui_mode: UiMode, automation: bool, + sys: &dyn Sys, logger: &Logger, ) -> Result<(), AppError> { trace_event( @@ -1212,7 +1254,7 @@ fn cleanup( thread::sleep(Duration::from_millis(200)); } if target_exe.exists() { - win::remove_file_with_fallback(&target_exe, &effective_logger)?; + sys.remove_file_with_fallback(&target_exe, &effective_logger)?; reboot_required = target_exe.exists(); } if let Some(install_root) = install_root { @@ -1220,22 +1262,22 @@ fn cleanup( win::remove_directory_if_exists(&install_root, &effective_logger)?; } } - reboot_required |= schedule_helper_self_cleanup(&effective_logger)?; + reboot_required |= sys.schedule_helper_self_cleanup(&effective_logger)?; if ui_mode == UiMode::Gui && !automation { if reboot_required { - if ui::prompt_uninstall_reboot(&app_name)? { - spawn_reboot(&effective_logger)?; + if sys.ui_prompt_uninstall_reboot(&app_name)? { + sys.spawn_reboot(&effective_logger)?; } } else { - ui::report_uninstall_success(&app_name)?; + sys.ui_report_uninstall_success(&app_name)?; } } else if ui_mode == UiMode::Tui { if reboot_required { println!( - "{app_name} uninstalled sucessfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now." + "{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 prompt_reboot_tui()? { - spawn_reboot(&effective_logger)?; + if sys.prompt_reboot_tui()? { + sys.spawn_reboot(&effective_logger)?; } } else { println!("{app_name} uninstalled successfully!"); @@ -1247,9 +1289,10 @@ fn cleanup( fn ensure_elevation_if_needed( required: bool, relaunch: bool, + sys: &dyn Sys, logger: &Logger, ) -> Result<(), AppError> { - if !required || win::is_elevated(logger)? { + if !required || sys.is_elevated(logger)? { trace_event( "elevation_ok", json!({"required": required, "relaunch": relaunch}), @@ -1258,7 +1301,7 @@ fn ensure_elevation_if_needed( } if relaunch { trace_event("elevation_relaunch", json!({})); - win::relaunch_as_admin(logger)?; + sys.relaunch_as_admin(logger)?; return Err(AppError::Message("__elevated_relaunch__".into())); } trace_event("elevation_required_error", json!({})); @@ -1341,6 +1384,7 @@ fn register_uninstall_entry( runtime: &InstallRuntime, install_root: &Path, uninstall_exe_path: &Path, + sys: &dyn Sys, logger: &Logger, ) -> Result<(), AppError> { let uninstall_command = format!( @@ -1368,7 +1412,7 @@ fn register_uninstall_entry( "register_uninstall_value", json!({"key":runtime.uninstall_registry_key,"name":name,"value":value}), ); - win::set_registry_string( + sys.set_registry_string( runtime.uninstall_registry_root, &runtime.uninstall_registry_key, name, @@ -1379,12 +1423,13 @@ fn register_uninstall_entry( Ok(()) } -fn spawn_cleanup_helper( +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()?; @@ -1401,6 +1446,9 @@ fn spawn_cleanup_helper( 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 { @@ -1430,68 +1478,41 @@ fn start_tui_progress(ui_mode: UiMode, label: String) -> Option { } } -fn parse_ui_preferences(args: &[OsString]) -> UiPreferences { - let mut preferences = UiPreferences { - headless: false, - headed: false, - automation: false, - }; - for arg in args.iter().skip(1) { - let value = arg.to_string_lossy(); - if value == "--headless" { - preferences.headless = true; - } else if value == "--headed" { - preferences.headed = true; - } else if value == "--automation" { - preferences.automation = true; - } - } - preferences -} - fn ui_preferences_from_cli(cli: &Cli) -> UiPreferences { UiPreferences { + json: cli.json, headless: cli.headless, headed: cli.headed, automation: cli.automation, } } -fn is_bundled_runtime_invocation(args: &[OsString]) -> bool { - let has_subcommand = args - .iter() - .skip(1) - .map(|arg| arg.to_string_lossy().to_ascii_lowercase()) - .any(|arg| { - matches!( - arg.as_str(), - "package" | "install" | "uninstall" | "cleanup" - ) - }); - !has_subcommand -} - fn select_ui( - phase: UiPhase, 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); } - if io::stdout().is_terminal() && win::is_parent_powershell(logger)? { - return Ok(UiMode::Tui); - } - if !io::stdout().is_terminal() { - return Ok(UiMode::Gui); - } - Ok(match phase { - UiPhase::Install => UiMode::None, - UiPhase::Uninstall | UiPhase::Cleanup => UiMode::None, - }) + 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 { @@ -1504,33 +1525,34 @@ fn ui_mode_name(ui_mode: UiMode) -> &'static str { fn start_gui_progress( ui_mode: UiMode, + sys: &dyn Sys, title: &str, - app_name: &str, total_steps: usize, -) -> Result, AppError> { +) -> Result>, AppError> { trace_event( "gui_progress_start", json!({ "ui_mode": ui_mode_name(ui_mode), "title": title, - "app_name": app_name, "total_steps": total_steps.max(1) }), ); + 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(ui::GuiProgress::start( + Ok(Some(Box::new(ui::GuiProgress::start( + title, title, - &format!("{title}"), total_steps.max(1), - )?)) + )?))) } else { - let _ = app_name; Ok(None) } } fn advance_gui_progress( - gui_progress: &mut Option, + gui_progress: &mut Option>, current_step: usize, message: &str, ) -> Result<(), AppError> { @@ -1544,8 +1566,17 @@ fn advance_gui_progress( Ok(()) } +fn advance_gui_progress_step( + gui_progress: &mut Option>, + current_step: &mut usize, + message: &str, +) -> Result<(), AppError> { + *current_step += 1; + advance_gui_progress(gui_progress, *current_step, message) +} + fn finish_gui_progress( - gui_progress: &mut Option, + gui_progress: &mut Option>, message: &str, ) -> Result<(), AppError> { trace_event("progress_finish", json!({"message": message})); @@ -1556,18 +1587,68 @@ fn finish_gui_progress( } fn fail_gui_progress( - gui_progress: &mut Option, - message: &str, + gui_progress: &mut Option>, + app_name: &str, + operation: &str, + err: &AppError, + wait_for_close: bool, ) -> Result<(), AppError> { - trace_event("progress_fail", json!({"message": message})); + 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.finish(message)?; + progress.fail( + app_name, + operation, + &message, + &err.to_string(), + error_errata(app_name, operation, err), + 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) -> 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, + "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, + gui_progress: &mut Option>, bytes: &[u8], ) -> Result<(), AppError> { if bytes.is_empty() { @@ -1596,7 +1677,7 @@ fn total_uninstall_steps(journal: &Journal) -> usize { journal.actions.len() + journal.purge.registry_branches.len() + journal.purge.paths.len() + 2 } -fn schedule_helper_self_cleanup(logger: &Logger) -> Result { +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!( @@ -1656,7 +1737,7 @@ fn powershell_single_quote(value: &str) -> String { value.replace('\'', "''") } -fn spawn_reboot(logger: &Logger) -> Result<(), AppError> { +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); @@ -1665,7 +1746,7 @@ fn spawn_reboot(logger: &Logger) -> Result<(), AppError> { Ok(()) } -fn prompt_reboot_tui() -> Result { +pub(crate) fn prompt_reboot_tui() -> Result { print!("Restart now? [y/N]: "); io::stdout().flush()?; let mut input = String::new(); @@ -1761,7 +1842,7 @@ fn execute_script( script: &ScriptSpec, manifest_dir: Option<&Path>, working_directory: Option<&Path>, - gui_progress: &mut Option, + gui_progress: &mut Option>, ) -> Result<(), AppError> { trace_event( "script_start", @@ -1796,20 +1877,20 @@ fn execute_script( Ok(()) } -fn purge_path(path: &Path, logger: &Logger) -> Result<(), AppError> { +fn purge_path(path: &Path, sys: &dyn Sys, logger: &Logger) -> Result<(), AppError> { if !path.exists() { return Ok(()); } if path.is_file() { - return win::remove_file_with_fallback(path, logger); + 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, logger)?; + purge_path(&child, sys, logger)?; } else { - win::remove_file_with_fallback(&child, logger)?; + sys.remove_file_with_fallback(&child, logger)?; } } win::remove_directory_if_exists(path, logger) @@ -1831,14 +1912,42 @@ 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 { - path.to_string_lossy() - .replace('/', "\\") - .to_ascii_lowercase() + 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 { @@ -1849,3 +1958,1895 @@ fn absolutize(base: Option<&Path>, value: &str) -> PathBuf { 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 bundle = EmbeddedBundle { + metadata: PackagedApp { + app_name: "Round Trip App".to_string(), + manifest: BUNDLE_MANIFEST.to_string(), + }, + files: vec![ + EmbeddedFile { + relative_path: BUNDLE_MANIFEST.to_string(), + 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: BUNDLE_MANIFEST.to_string(), + }, + 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: BUNDLE_MANIFEST.to_string(), + }, + 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: BUNDLE_MANIFEST.to_string(), + }, + 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("app.toml"); + 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(), + }; + + 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.files.len(), 2); + assert_eq!(bundle.files[0].relative_path, BUNDLE_MANIFEST); + 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("source.toml"); + 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, &[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, BUNDLE_MANIFEST); + 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()], + }, + ); + + 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\\SerdeApp".to_string()], + paths: vec!["C:\\Apps\\Serde\\cache".to_string()], + }, + }; + + 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 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 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("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", 0) + .unwrap() + .is_none() + ); + advance_gui_progress(&mut progress, 1, "step").unwrap(); + advance_gui_progress_step(&mut progress, &mut step, "next").unwrap(); + finish_gui_progress(&mut progress, "done").unwrap(); + fail_gui_progress( + &mut progress, + "App", + "install", + &AppError::Message("boom".to_string()), + 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())); + + 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!(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()], + }, + }; + 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("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(), + }; + fs::write(&journal_path, serde_json::to_vec_pretty(&journal).unwrap()).unwrap(); + + uninstall( + &journal_path, + false, + UiMode::None, + true, + &WinSys, + None, + &quiet_logger(), + ) + .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![DirectorySpec { + path: "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(), + }; + 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![], + }, + }; + 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![DirectorySpec { + path: "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()], + }, + }; + 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, + }; + 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(), + }; + 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()], + }, + }; + 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, + UiConfirmInstall(String), + UiReportSuccess(String), + 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, + message: String, + }, + Log(String), + Finish(String), + Fail { + app_name: String, + operation: String, + message: String, + error: String, + wait_for_close: bool, + }, + } + + #[derive(Default)] + struct MockProgressSink { + calls: Arc>>, + } + + impl MockProgressSink { + fn new() -> Self { + Self::default() + } + + fn handle(&self) -> Arc>> { + self.calls.clone() + } + } + + impl ProgressSink for MockProgressSink { + fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError> { + self.calls.lock().unwrap().push(SinkCall::Advance { + current_step, + 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) -> Result<(), AppError> { + self.calls + .lock() + .unwrap() + .push(SinkCall::Finish(message.to_string())); + Ok(()) + } + + 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_confirm_install: Mutex, + ui_prompt_uninstall_reboot: Mutex, + schedule_helper_self_cleanup: Mutex, + prompt_reboot_tui: Mutex, + has_embedded_bundle: Mutex, + progress_sink_calls: 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_confirm_install(&self, value: bool) { + *self.ui_confirm_install.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 + } + } + + 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_confirm_install(&self, app_name: &str) -> Result { + self.calls + .lock() + .unwrap() + .push(SysCall::UiConfirmInstall(app_name.to_string())); + Ok(*self.ui_confirm_install.lock().unwrap()) + } + + fn ui_report_success(&self, app_name: &str) -> Result<(), AppError> { + self.calls + .lock() + .unwrap() + .push(SysCall::UiReportSuccess(app_name.to_string())); + Ok(()) + } + + 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(), + }; + 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(), + }; + 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("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 without + // calling ui_report_success. + #[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(_))); + assert!( + !sys.recorded() + .iter() + .any(|c| matches!(c, SysCall::UiReportSuccess(_))) + ); + } + + // (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("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![], + }, + }; + 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, + None, + &quiet_logger(), + ) + .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(), + }; + 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, + None, + &quiet_logger(), + ) + .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(), + }; + 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, + None, + &quiet_logger(), + ) + .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, "step 2").unwrap(); + sink.log("note").unwrap(); + sink.finish("done").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, message } if message == "step 2" + )); + assert!(matches!(&calls[1], SinkCall::Log(s) if s == "note")); + assert!(matches!(&calls[2], SinkCall::Finish(s) if s == "done")); + 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![DirectorySpec { + path: "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()], + }, + } + } + + 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, + } + } +} diff --git a/src/sys.rs b/src/sys.rs new file mode 100644 index 0000000..35fcdf6 --- /dev/null +++ b/src/sys.rs @@ -0,0 +1,175 @@ +use crate::ui::ProgressSink; +use crate::{AppError, Logger, RegistryRoot, UiMode}; +use std::path::Path; + +/// Abstraction over every external boundary the installer engine touches: +/// UAC elevation, reboot, cleanup-helper self-delete, registry writes, the +/// embedded-bundle probe, the high-level UI prompts, and the MoveFileEx +/// pending-rename fallback. +/// +/// This trait exists so the `install` / `uninstall` / `cleanup` / +/// `run_bundled_installer` orchestration code can be unit-tested with mocks +/// without spawning Win32 / process / GUI IPC side-effects. +pub(crate) trait Sys: Send + Sync { + // (1) UAC relaunch + fn is_elevated(&self, logger: &Logger) -> Result; + fn relaunch_as_admin(&self, logger: &Logger) -> Result<(), AppError>; + + // (2) reboot + fn spawn_reboot(&self, logger: &Logger) -> Result<(), AppError>; + fn prompt_reboot_tui(&self) -> Result; + + // (3) cleanup helper self-delete + 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>; + fn schedule_helper_self_cleanup(&self, logger: &Logger) -> Result; + + // (4) registry writes + fn set_registry_string( + &self, + root: RegistryRoot, + subkey: &str, + name: &str, + value: &str, + logger: &Logger, + ) -> Result<(), AppError>; + fn delete_registry_tree( + &self, + root: RegistryRoot, + subkey: &str, + logger: &Logger, + ) -> Result<(), AppError>; + + // (5) bundled-installer probe + fn has_embedded_bundle(&self) -> bool; + + // (6) UI prompts (high level — the GuiProgress trait handles the live IPC) + fn ui_available(&self) -> bool; + fn ui_confirm_install(&self, app_name: &str) -> Result; + fn ui_report_success(&self, app_name: &str) -> Result<(), AppError>; + fn ui_report_error(&self, message: &str) -> Result<(), AppError>; + fn ui_report_uninstall_success(&self, app_name: &str) -> Result<(), AppError>; + fn ui_prompt_uninstall_reboot(&self, app_name: &str) -> Result; + + // (7) MoveFileEx reboot fallback + fn remove_file_with_fallback(&self, path: &Path, logger: &Logger) -> Result<(), AppError>; + + // Optional: lets MockSys substitute a recording ProgressSink in tests. + // Default returns None so install/uninstall fall back to constructing a + // real GuiProgress via crate::start_gui_progress when desired. + fn start_progress( + &self, + _ui_mode: UiMode, + _title: &str, + _total_steps: usize, + ) -> Result>, AppError> { + Ok(None) + } +} + +/// Production implementation that delegates to the real Win32 / process / +/// GUI IPC functions. +pub(crate) struct WinSys; + +impl Sys for WinSys { + fn is_elevated(&self, logger: &Logger) -> Result { + crate::win::is_elevated(logger) + } + + fn relaunch_as_admin(&self, logger: &Logger) -> Result<(), AppError> { + crate::win::relaunch_as_admin(logger) + } + + fn spawn_reboot(&self, logger: &Logger) -> Result<(), AppError> { + crate::spawn_reboot(logger) + } + + fn prompt_reboot_tui(&self) -> Result { + crate::prompt_reboot_tui() + } + + 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> { + crate::spawn_cleanup_helper( + target_exe, + install_root, + app_name, + ui_mode, + automation, + json, + logger, + ) + } + + fn schedule_helper_self_cleanup(&self, logger: &Logger) -> Result { + crate::schedule_helper_self_cleanup(logger) + } + + fn set_registry_string( + &self, + root: RegistryRoot, + subkey: &str, + name: &str, + value: &str, + logger: &Logger, + ) -> Result<(), AppError> { + crate::win::set_registry_string(root, subkey, name, value, logger) + } + + fn delete_registry_tree( + &self, + root: RegistryRoot, + subkey: &str, + logger: &Logger, + ) -> Result<(), AppError> { + crate::win::delete_registry_tree(root, subkey, logger) + } + + fn has_embedded_bundle(&self) -> bool { + crate::has_embedded_bundle() + } + + fn ui_available(&self) -> bool { + crate::ui::is_available() + } + + fn ui_confirm_install(&self, app_name: &str) -> Result { + crate::ui::confirm_install(app_name) + } + + fn ui_report_success(&self, app_name: &str) -> Result<(), AppError> { + crate::ui::report_success(app_name) + } + + fn ui_report_error(&self, message: &str) -> Result<(), AppError> { + crate::ui::report_error(message) + } + + fn ui_report_uninstall_success(&self, app_name: &str) -> Result<(), AppError> { + crate::ui::report_uninstall_success(app_name) + } + + fn ui_prompt_uninstall_reboot(&self, app_name: &str) -> Result { + crate::ui::prompt_uninstall_reboot(app_name) + } + + fn remove_file_with_fallback(&self, path: &Path, logger: &Logger) -> Result<(), AppError> { + crate::win::remove_file_with_fallback(path, logger) + } +} diff --git a/src/ui.rs b/src/ui.rs index 6681337..47403bd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,7 +10,73 @@ use std::thread; use std::time::{Duration, Instant}; const CREATE_NO_WINDOW: u32 = 0x0800_0000; -const UI_EXE_BYTES: &[u8] = include_bytes!(env!("COVENANT_SETUP_UI_EXE")); +const UI_EXE_NAME: &str = "Covenant.Setup.Ui.exe"; + +#[cfg(covenant_setup_embedded_ui)] +fn embedded_ui_bytes() -> Option<&'static [u8]> { + Some(include_bytes!(env!("COVENANT_SETUP_UI_EXE"))) +} + +#[cfg(not(covenant_setup_embedded_ui))] +fn embedded_ui_bytes() -> Option<&'static [u8]> { + None +} + +pub fn is_available() -> bool { + embedded_ui_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, message: &str) -> Result<(), AppError>; + fn log(&mut self, message: &str) -> Result<(), AppError>; + fn finish(&mut self, message: &str) -> Result<(), AppError>; + fn fail( + &mut self, + app_name: &str, + operation: &str, + message: &str, + error: &str, + errata: Value, + wait_for_close: bool, + ) -> Result<(), AppError>; +} + +impl ProgressSink for GuiProgress { + fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError> { + GuiProgress::advance(self, current_step, message) + } + + fn log(&mut self, message: &str) -> Result<(), AppError> { + GuiProgress::log(self, message) + } + + fn finish(&mut self, message: &str) -> Result<(), AppError> { + GuiProgress::finish(self, message) + } + + 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, + ) + } +} pub struct GuiProgress { session: CSharpUiSession, @@ -54,6 +120,29 @@ impl GuiProgress { "message": message, })) } + + 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 confirm_install(app_name: &str) -> Result { @@ -108,7 +197,7 @@ pub fn prompt_uninstall_reboot(app_name: &str) -> Result { let result = prompt( "covenant-setup", &format!( - "{app_name} uninstalled sucessfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now." + "{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, @@ -202,14 +291,17 @@ struct CSharpUiSession { reader: BufReader, writer: fs::File, exe_path: PathBuf, + remove_exe_on_drop: bool, 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 exe_path = extract_ui_exe()?; + let prepared_exe = prepare_ui_exe()?; + let exe_path = prepared_exe.path; crate::trace_event("ui_extracted", json!({"exe_path": &exe_path})); let mut child = Command::new(&exe_path) .creation_flags(CREATE_NO_WINDOW) @@ -229,7 +321,9 @@ impl CSharpUiSession { reader: BufReader::new(pipe), writer, exe_path, + remove_exe_on_drop: prepared_exe.remove_on_drop, closed: false, + child_exited: false, }) } @@ -253,10 +347,27 @@ impl CSharpUiSession { 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(()) + } } impl Drop for CSharpUiSession { fn drop(&mut self) { + if self.child_exited { + if self.remove_exe_on_drop { + let _ = fs::remove_file(&self.exe_path); + } + return; + } if !self.closed { crate::trace_event("ui_close_send", json!({"pid": self.child.id()})); let _ = self.send(&json!({"type": "close"})); @@ -265,7 +376,9 @@ impl Drop for CSharpUiSession { for _ in 0..20 { if self.child.try_wait().ok().flatten().is_some() { crate::trace_event("ui_exited", json!({"pid": self.child.id()})); - let _ = fs::remove_file(&self.exe_path); + if self.remove_exe_on_drop { + let _ = fs::remove_file(&self.exe_path); + } return; } thread::sleep(Duration::from_millis(50)); @@ -273,7 +386,9 @@ impl Drop for CSharpUiSession { let _ = self.child.kill(); let _ = self.child.wait(); crate::trace_event("ui_killed", json!({"pid": self.child.id()})); - let _ = fs::remove_file(&self.exe_path); + if self.remove_exe_on_drop { + let _ = fs::remove_file(&self.exe_path); + } } } @@ -316,7 +431,27 @@ fn connect_pipe(pipe_path: &str, child: &mut Child) -> Result Result { +struct PreparedUiExe { + path: PathBuf, + remove_on_drop: bool, +} + +fn prepare_ui_exe() -> Result { + if let Some(bytes) = embedded_ui_bytes() { + return extract_ui_exe(bytes); + } + if let Some(path) = sidecar_ui_exe() { + return Ok(PreparedUiExe { + path, + remove_on_drop: false, + }); + } + Err(AppError::Message(format!( + "C# UI helper is not bundled and no {UI_EXE_NAME} was found next to the installer" + ))) +} + +fn extract_ui_exe(bytes: &[u8]) -> Result { let root = std::env::temp_dir().join("covenant-setup-ui"); fs::create_dir_all(&root)?; let path = root.join(format!( @@ -324,8 +459,16 @@ fn extract_ui_exe() -> Result { process::id(), unique_suffix() )); - fs::write(&path, UI_EXE_BYTES)?; - Ok(path) + fs::write(&path, bytes)?; + Ok(PreparedUiExe { + path, + remove_on_drop: true, + }) +} + +fn sidecar_ui_exe() -> Option { + let path = std::env::current_exe().ok()?.parent()?.join(UI_EXE_NAME); + path.is_file().then_some(path) } fn message_summary(value: &Value) -> Value { @@ -339,6 +482,91 @@ fn message_summary(value: &Value) -> Value { fn unique_suffix() -> u128 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis()) + .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::OkCancel.as_str(), "ok_cancel"); + 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_exe_writes_temp_executable_and_marks_it_for_cleanup() { + let prepared = extract_ui_exe(b"fake exe").unwrap(); + + assert_eq!(fs::read(&prepared.path).unwrap(), b"fake exe"); + assert!(prepared.remove_on_drop); + fs::remove_file(prepared.path).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 prepared.remove_on_drop { + fs::remove_file(prepared.path).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); + } +} diff --git a/src/win.rs b/src/win.rs index 0ff4b94..b26cb26 100644 --- a/src/win.rs +++ b/src/win.rs @@ -17,9 +17,6 @@ use windows::Win32::System::Com::{ CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree, CoUninitialize, IPersistFile, }; -use windows::Win32::System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, -}; 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, @@ -28,7 +25,7 @@ use windows::Win32::System::Registry::{ use windows::Win32::System::RestartManager::{ RM_PROCESS_INFO, RmEndSession, RmGetList, RmRegisterResources, RmStartSession, }; -use windows::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, OpenProcessToken}; +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, @@ -44,75 +41,6 @@ pub struct PathResolver { admin_roots: Vec, } -pub fn is_parent_powershell(logger: &Logger) -> Result { - let current_pid = unsafe { GetCurrentProcessId() }; - logger.unsafe_enter("CreateToolhelp32Snapshot", json!({})); - let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)? }; - logger.unsafe_exit("CreateToolhelp32Snapshot", json!({"ok": true})); - - let result = (|| -> Result { - let mut entry = PROCESSENTRY32W { - dwSize: std::mem::size_of::() as u32, - ..Default::default() - }; - logger.unsafe_enter("Process32FirstW", json!({})); - let first = unsafe { Process32FirstW(snapshot, &mut entry) }; - logger.unsafe_exit("Process32FirstW", json!({"ok": first.is_ok()})); - if first.is_err() { - return Ok(false); - } - - let mut parent_pid = None; - loop { - if entry.th32ProcessID == current_pid { - parent_pid = Some(entry.th32ParentProcessID); - break; - } - logger.unsafe_enter("Process32NextW", json!({})); - let next = unsafe { Process32NextW(snapshot, &mut entry) }; - logger.unsafe_exit("Process32NextW", json!({"ok": next.is_ok()})); - if next.is_err() { - break; - } - } - - let Some(parent_pid) = parent_pid else { - return Ok(false); - }; - - let mut entry = PROCESSENTRY32W { - dwSize: std::mem::size_of::() as u32, - ..Default::default() - }; - logger.unsafe_enter("Process32FirstW", json!({"search_parent": parent_pid})); - let first = unsafe { Process32FirstW(snapshot, &mut entry) }; - logger.unsafe_exit("Process32FirstW", json!({"ok": first.is_ok()})); - if first.is_err() { - return Ok(false); - } - - loop { - if entry.th32ProcessID == parent_pid { - let exe = wide_array_to_string(&entry.szExeFile); - let exe_lower = exe.to_ascii_lowercase(); - return Ok(exe_lower.contains("powershell") - || exe_lower == "pwsh.exe" - || exe_lower == "pwsh"); - } - logger.unsafe_enter("Process32NextW", json!({"search_parent": parent_pid})); - let next = unsafe { Process32NextW(snapshot, &mut entry) }; - logger.unsafe_exit("Process32NextW", json!({"ok": next.is_ok()})); - if next.is_err() { - break; - } - } - Ok(false) - })(); - - close_handle(snapshot, logger)?; - result -} - impl PathResolver { pub fn new(logger: &Logger) -> Result { let program_files_x64 = known_folder(&FOLDERID_ProgramFilesX64, logger)?; @@ -142,9 +70,9 @@ impl PathResolver { 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}\\")) - }) + self.admin_roots + .iter() + .any(|root| candidate == *root || candidate.starts_with(&format!("{root}\\"))) } #[cfg(test)] @@ -186,10 +114,7 @@ fn normalize_for_admin_match(path: &Path) -> String { // 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 == '"'); + let needs_quoting = arg.is_empty() || arg.chars().any(|c| c == ' ' || c == '\t' || c == '"'); if !needs_quoting { return arg.to_string(); } @@ -590,14 +515,6 @@ fn pwstr_to_path(raw: PWSTR, logger: &Logger) -> Result { } } -fn wide_array_to_string(buffer: &[u16]) -> String { - let len = buffer - .iter() - .position(|value| *value == 0) - .unwrap_or(buffer.len()); - String::from_utf16_lossy(&buffer[..len]) -} - fn close_handle(handle: HANDLE, logger: &Logger) -> Result<(), AppError> { logger.unsafe_enter("CloseHandle", json!({})); let result = unsafe { CloseHandle(handle) }; @@ -673,6 +590,38 @@ impl Utf16Arg { 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"), @@ -739,10 +688,137 @@ mod tests { 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("C:\\Apps\\foo.exe"), + "C:\\Apps\\foo.exe" + ); assert_eq!(quote_command_line_arg("--json"), "--json"); } @@ -775,15 +851,9 @@ mod tests { #[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\"" - ); + 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\"" - ); + assert_eq!(quote_command_line_arg("a\\\\\"b"), "\"a\\\\\\\\\\\"b\""); } #[test] @@ -811,6 +881,17 @@ mod tests { } } + #[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 { diff --git a/ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj b/ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj new file mode 100644 index 0000000..9d99079 --- /dev/null +++ b/ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0-windows + true + enable + enable + false + true + Covenant.Setup.Ui.Tests + Covenant.Setup.Ui.Tests + + + + + + + + + + + + + diff --git a/ui/Covenant.Setup.Ui.Tests/InstallerUiFormHelperTests.cs b/ui/Covenant.Setup.Ui.Tests/InstallerUiFormHelperTests.cs new file mode 100644 index 0000000..f376ef0 --- /dev/null +++ b/ui/Covenant.Setup.Ui.Tests/InstallerUiFormHelperTests.cs @@ -0,0 +1,142 @@ +using System.Text.Json; +using Covenant.Setup.Ui; +using Xunit; + +namespace Covenant.Setup.Ui.Tests; + +public class InstallerUiFormHelperTests +{ + [Fact] + public void BuildErrataJson_uses_provided_errata_when_present() + { + using var doc = JsonDocument.Parse("""{"counter":42,"label":"alpha"}"""); + var msg = new UiMessage + { + AppName = "MyApp", + Operation = "install", + Message = "Failed", + Error = "E_FAIL", + Errata = doc.RootElement.Clone() + }; + + var json = InstallerUiForm.BuildErrataJson(msg); + + using var parsed = JsonDocument.Parse(json); + Assert.Equal(JsonValueKind.Object, parsed.RootElement.ValueKind); + Assert.Equal(42, parsed.RootElement.GetProperty("counter").GetInt32()); + Assert.Equal("alpha", parsed.RootElement.GetProperty("label").GetString()); + Assert.False(parsed.RootElement.TryGetProperty("app_name", out _)); + } + + [Fact] + public void BuildErrataJson_falls_back_to_synthesized_payload_when_errata_null() + { + var msg = new UiMessage + { + AppName = "MyApp", + Operation = "install", + Message = "Failed", + Error = "E_FAIL", + Errata = null + }; + + var json = InstallerUiForm.BuildErrataJson(msg); + + using var parsed = JsonDocument.Parse(json); + Assert.Equal("MyApp", parsed.RootElement.GetProperty("app_name").GetString()); + Assert.Equal("install", parsed.RootElement.GetProperty("operation").GetString()); + Assert.Equal("Failed", parsed.RootElement.GetProperty("message").GetString()); + Assert.Equal("E_FAIL", parsed.RootElement.GetProperty("error").GetString()); + } + + [Fact] + public void BuildErrataJson_falls_back_when_errata_is_null_jsonelement() + { + using var doc = JsonDocument.Parse("null"); + var msg = new UiMessage + { + AppName = "MyApp", + Operation = "uninstall", + Errata = doc.RootElement.Clone() + }; + + var json = InstallerUiForm.BuildErrataJson(msg); + + using var parsed = JsonDocument.Parse(json); + Assert.Equal("MyApp", parsed.RootElement.GetProperty("app_name").GetString()); + Assert.Equal("uninstall", parsed.RootElement.GetProperty("operation").GetString()); + } + + [Fact] + public void SafeMessageSummary_extracts_known_fields_from_valid_json() + { + const string line = """{"type":"progress","id":"x1","message":"Step 1","extra":"ignored"}"""; + + var summary = InstallerUiForm.SafeMessageSummary(line); + var json = JsonSerializer.Serialize(summary); + + using var parsed = JsonDocument.Parse(json); + Assert.Equal("progress", parsed.RootElement.GetProperty("Type").GetString()); + Assert.Equal("x1", parsed.RootElement.GetProperty("Id").GetString()); + Assert.Equal("Step 1", parsed.RootElement.GetProperty("Message").GetString()); + } + + [Fact] + public void SafeMessageSummary_returns_raw_length_for_invalid_json() + { + var summary = InstallerUiForm.SafeMessageSummary("not-json-at-all"); + var json = JsonSerializer.Serialize(summary); + + using var parsed = JsonDocument.Parse(json); + Assert.Equal("not-json-at-all".Length, parsed.RootElement.GetProperty("RawLength").GetInt32()); + Assert.False(parsed.RootElement.TryGetProperty("Type", out _)); + } + + [Fact] + public void SafeMessageSummary_returns_null_fields_when_known_keys_absent() + { + var summary = InstallerUiForm.SafeMessageSummary("{}"); + var json = JsonSerializer.Serialize(summary); + + using var parsed = JsonDocument.Parse(json); + Assert.Equal(JsonValueKind.Null, parsed.RootElement.GetProperty("Type").ValueKind); + Assert.Equal(JsonValueKind.Null, parsed.RootElement.GetProperty("Id").ValueKind); + Assert.Equal(JsonValueKind.Null, parsed.RootElement.GetProperty("Message").ValueKind); + } + + [Theory] + [InlineData("ok_cancel", MessageBoxButtons.OKCancel)] + [InlineData("yes_no", MessageBoxButtons.YesNo)] + [InlineData("ok", MessageBoxButtons.OK)] + [InlineData(null, MessageBoxButtons.OK)] + [InlineData("unknown", MessageBoxButtons.OK)] + public void MapButtons_handles_known_and_default_values(string? input, MessageBoxButtons expected) + { + Assert.Equal(expected, InstallerUiForm.MapButtons(input)); + } + + [Theory] + [InlineData("error", MessageBoxIcon.Error)] + [InlineData("warning", MessageBoxIcon.Warning)] + [InlineData("information", MessageBoxIcon.Information)] + [InlineData(null, MessageBoxIcon.Information)] + [InlineData("anything-else", MessageBoxIcon.Information)] + public void MapIcon_handles_known_and_default_values(string? input, MessageBoxIcon expected) + { + Assert.Equal(expected, InstallerUiForm.MapIcon(input)); + } + + [Theory] + [InlineData(DialogResult.OK, "ok")] + [InlineData(DialogResult.Cancel, "cancel")] + [InlineData(DialogResult.Yes, "yes")] + [InlineData(DialogResult.No, "no")] + [InlineData(DialogResult.None, "none")] + [InlineData(DialogResult.Abort, "none")] + [InlineData(DialogResult.Retry, "none")] + [InlineData(DialogResult.Ignore, "none")] + public void MapDialogResult_maps_to_lowercase_token(DialogResult input, string expected) + { + Assert.Equal(expected, InstallerUiForm.MapDialogResult(input)); + } +} diff --git a/ui/Covenant.Setup.Ui.Tests/ProgramTests.cs b/ui/Covenant.Setup.Ui.Tests/ProgramTests.cs new file mode 100644 index 0000000..45213ac --- /dev/null +++ b/ui/Covenant.Setup.Ui.Tests/ProgramTests.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using Covenant.Setup.Ui; +using Xunit; + +namespace Covenant.Setup.Ui.Tests; + +public class ProgramTests +{ + [Fact] + public void ReadPipeName_returns_value_following_pipe_flag() + { + var name = Program.ReadPipeName(new[] { "--pipe", @"\\.\pipe\foo" }); + Assert.Equal(@"\\.\pipe\foo", name); + } + + [Fact] + public void ReadPipeName_is_case_insensitive_on_flag() + { + var name = Program.ReadPipeName(new[] { "--PIPE", "abc" }); + Assert.Equal("abc", name); + } + + [Fact] + public void ReadPipeName_finds_flag_among_other_args() + { + var name = Program.ReadPipeName(new[] { "--other", "x", "--pipe", "p1", "--more", "y" }); + Assert.Equal("p1", name); + } + + [Fact] + public void ReadPipeName_returns_null_when_flag_missing() + { + Assert.Null(Program.ReadPipeName(new[] { "--other", "x" })); + } + + [Fact] + public void ReadPipeName_returns_null_when_flag_is_last_arg_with_no_value() + { + Assert.Null(Program.ReadPipeName(new[] { "--pipe" })); + } + + [Fact] + public void ReadPipeName_returns_null_for_empty_args() + { + Assert.Null(Program.ReadPipeName(Array.Empty())); + } +} diff --git a/ui/Covenant.Setup.Ui.Tests/UiMessageJsonTests.cs b/ui/Covenant.Setup.Ui.Tests/UiMessageJsonTests.cs new file mode 100644 index 0000000..edf3186 --- /dev/null +++ b/ui/Covenant.Setup.Ui.Tests/UiMessageJsonTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using Covenant.Setup.Ui; +using Xunit; + +namespace Covenant.Setup.Ui.Tests; + +public class UiMessageJsonTests +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true + }; + + [Fact] + public void Deserializes_progress_message_with_snake_case_step_fields() + { + const string json = """ + {"type":"progress","message":"Copying","current_step":3,"total_steps":10} + """; + + var msg = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(msg); + Assert.Equal("progress", msg!.Type); + Assert.Equal("Copying", msg.Message); + Assert.Equal(3, msg.CurrentStep); + Assert.Equal(10, msg.TotalSteps); + } + + [Fact] + public void Deserializes_fail_message_with_app_name_and_errata() + { + const string json = """ + {"type":"fail","app_name":"MyApp","operation":"install","message":"Boom","error":"E_FAIL","errata":{"k":1}} + """; + + var msg = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(msg); + Assert.Equal("fail", msg!.Type); + Assert.Equal("MyApp", msg.AppName); + Assert.Equal("install", msg.Operation); + Assert.Equal("Boom", msg.Message); + Assert.Equal("E_FAIL", msg.Error); + Assert.NotNull(msg.Errata); + Assert.Equal(JsonValueKind.Object, msg.Errata!.Value.ValueKind); + } + + [Fact] + public void Deserializes_prompt_message_with_buttons_and_icon() + { + const string json = """ + {"type":"prompt","id":"p1","title":"Confirm","message":"Reboot now?","buttons":"yes_no","icon":"warning"} + """; + + var msg = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(msg); + Assert.Equal("p1", msg!.Id); + Assert.Equal("yes_no", msg.Buttons); + Assert.Equal("warning", msg.Icon); + } + + [Fact] + public void Deserializes_message_with_unknown_type_to_arbitrary_string() + { + var msg = JsonSerializer.Deserialize("""{"type":"unknown_type"}""", Options); + Assert.Equal("unknown_type", msg!.Type); + } + + [Fact] + public void Missing_type_round_trips_as_null() + { + var msg = JsonSerializer.Deserialize("{}", Options); + Assert.NotNull(msg); + Assert.Null(msg!.Type); + Assert.Null(msg.CurrentStep); + Assert.Null(msg.Errata); + } + + [Fact] + public void UiResponse_serializes_with_snake_case_property_names() + { + var response = new UiResponse { Type = "prompt_response", Id = "p1", Result = "yes" }; + var json = JsonSerializer.Serialize(response, Options); + Assert.Contains("\"type\":\"prompt_response\"", json); + Assert.Contains("\"id\":\"p1\"", json); + Assert.Contains("\"result\":\"yes\"", json); + } +} diff --git a/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj b/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj index 9ee17f8..28d1e06 100644 --- a/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj +++ b/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj @@ -1,7 +1,7 @@ WinExe - net8.0-windows + net10.0-windows true enable enable @@ -9,4 +9,7 @@ Covenant.Setup.Ui app.manifest + + + diff --git a/ui/Covenant.Setup.Ui/Program.cs b/ui/Covenant.Setup.Ui/Program.cs index 1540b34..d70e3f6 100644 --- a/ui/Covenant.Setup.Ui/Program.cs +++ b/ui/Covenant.Setup.Ui/Program.cs @@ -25,7 +25,7 @@ internal static class Program Application.Run(new InstallerUiForm(pipeName)); } - private static string? ReadPipeName(string[] args) + internal static string? ReadPipeName(string[] args) { for (var i = 0; i < args.Length - 1; i++) { @@ -51,10 +51,12 @@ internal sealed class InstallerUiForm : Form private readonly Label _statusLabel; private readonly ProgressBar _progressBar; private readonly TextBox _logBox; + private readonly Button _saveErrataButton; private readonly Button _closeButton; private StreamWriter? _writer; private readonly object _writerLock = new(); private bool _closeRequested; + private string? _errataJson; public InstallerUiForm(string pipeName) { @@ -95,6 +97,17 @@ internal sealed class InstallerUiForm : Form Font = new Font("Consolas", 9F) }; + _saveErrataButton = new Button + { + Text = "Save error data to local errata.json file?", + Enabled = false, + Visible = false, + Size = new Size(320, 28), + Location = new Point(ClientSize.Width - 432, ClientSize.Height - 40), + Anchor = AnchorStyles.Bottom | AnchorStyles.Right + }; + _saveErrataButton.Click += (_, _) => SaveErrata(); + _closeButton = new Button { Text = "Close", @@ -108,6 +121,7 @@ internal sealed class InstallerUiForm : Form Controls.Add(_statusLabel); Controls.Add(_progressBar); Controls.Add(_logBox); + Controls.Add(_saveErrataButton); Controls.Add(_closeButton); Shown += (_, _) => _ = Task.Run(RunPipeLoop); @@ -215,6 +229,11 @@ internal sealed class InstallerUiForm : Form }); return true; + case "fail": + UiTrace.Write("fail_message", new { message.AppName, message.Operation, message.Message, message.Error }); + BeginInvokeSafe(() => ApplyFailure(message)); + return true; + case "prompt": UiTrace.Write("prompt_show_requested", new { message.Id, message.Title, message.Buttons, message.Icon }); var result = ShowPrompt(message); @@ -254,6 +273,74 @@ internal sealed class InstallerUiForm : Form _progressBar.Value = Math.Max(0, Math.Min(100, current * 100 / total)); } + private void ApplyFailure(UiMessage message) + { + var operation = string.IsNullOrWhiteSpace(message.Operation) ? "complete" : message.Operation; + var appName = string.IsNullOrWhiteSpace(message.AppName) ? "unknown" : message.AppName; + var failureMessage = string.IsNullOrWhiteSpace(message.Message) + ? $"Error: program {appName} failed to {operation} completely!" + : message.Message; + + _statusLabel.Text = failureMessage; + _progressBar.Value = 100; + AppendLog(failureMessage); + if (!string.IsNullOrWhiteSpace(message.Error)) + { + AppendLog("Error details: " + message.Error); + } + + _errataJson = BuildErrataJson(message); + _saveErrataButton.Enabled = !string.IsNullOrWhiteSpace(_errataJson); + _saveErrataButton.Visible = true; + _closeButton.Enabled = true; + } + + private void SaveErrata() + { + if (string.IsNullOrWhiteSpace(_errataJson)) + { + return; + } + + try + { + var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(root)) + { + root = Environment.CurrentDirectory; + } + + var directory = Path.Combine(root, "CovenantSetup"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "errata.json"); + File.WriteAllText(path, _errataJson, new UTF8Encoding(false)); + AppendLog("Saved error data to " + path); + MessageBox.Show(this, "Error data saved to " + path, "covenant-setup", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + UiTrace.Write("errata_save_error", new { ex.Message, ex.GetType().FullName, ex.StackTrace }); + MessageBox.Show(this, "Unable to save errata.json: " + ex.Message, "covenant-setup", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + internal static string BuildErrataJson(UiMessage message) + { + if (message.Errata is JsonElement errata && + errata.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null) + { + return JsonSerializer.Serialize(errata, new JsonSerializerOptions { WriteIndented = true }); + } + + return JsonSerializer.Serialize(new + { + app_name = message.AppName, + operation = message.Operation, + message = message.Message, + error = message.Error + }, new JsonSerializerOptions { WriteIndented = true }); + } + private string ShowPrompt(UiMessage message) { if (InvokeRequired) @@ -261,18 +348,8 @@ internal sealed class InstallerUiForm : Form return (string)Invoke(new Func(() => ShowPrompt(message))); } - var buttons = message.Buttons switch - { - "ok_cancel" => MessageBoxButtons.OKCancel, - "yes_no" => MessageBoxButtons.YesNo, - _ => MessageBoxButtons.OK - }; - var icon = message.Icon switch - { - "error" => MessageBoxIcon.Error, - "warning" => MessageBoxIcon.Warning, - _ => MessageBoxIcon.Information - }; + var buttons = MapButtons(message.Buttons); + var icon = MapIcon(message.Icon); var result = MessageBox.Show( this, @@ -282,16 +359,32 @@ internal sealed class InstallerUiForm : Form icon); UiTrace.Write("prompt_closed", new { message.Id, Result = result.ToString() }); - return result switch - { - DialogResult.OK => "ok", - DialogResult.Cancel => "cancel", - DialogResult.Yes => "yes", - DialogResult.No => "no", - _ => "none" - }; + return MapDialogResult(result); } + internal static MessageBoxButtons MapButtons(string? buttons) => buttons switch + { + "ok_cancel" => MessageBoxButtons.OKCancel, + "yes_no" => MessageBoxButtons.YesNo, + _ => MessageBoxButtons.OK + }; + + internal static MessageBoxIcon MapIcon(string? icon) => icon switch + { + "error" => MessageBoxIcon.Error, + "warning" => MessageBoxIcon.Warning, + _ => MessageBoxIcon.Information + }; + + internal static string MapDialogResult(DialogResult result) => result switch + { + DialogResult.OK => "ok", + DialogResult.Cancel => "cancel", + DialogResult.Yes => "yes", + DialogResult.No => "no", + _ => "none" + }; + private void WriteResponse(UiResponse response) { lock (_writerLock) @@ -333,7 +426,7 @@ internal sealed class InstallerUiForm : Form _logBox.ScrollToCaret(); } - private static object SafeMessageSummary(string line) + internal static object SafeMessageSummary(string line) { try { @@ -419,6 +512,18 @@ internal sealed class UiMessage [JsonPropertyName("message")] public string? Message { get; set; } + [JsonPropertyName("app_name")] + public string? AppName { get; set; } + + [JsonPropertyName("operation")] + public string? Operation { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("errata")] + public JsonElement? Errata { get; set; } + [JsonPropertyName("current_step")] public int? CurrentStep { get; set; }