From a5d6c8aa99f798eb4efe5b65951656ed7679fae1 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 27 Mar 2026 21:18:48 -0500 Subject: [PATCH 01/13] wip --- .gitignore | 4 +- CLAUDE.md | 58 + Cargo.lock | 465 ++++++++ Cargo.toml | 27 + README.md | 144 +++ build.rs | 6 + examples/README.md | 42 + examples/install.toml | 35 + examples/payload/post_install.ps1 | 3 + examples/payload/sample_app.cmd | 3 + project_mvp.md | 55 + src/main.rs | 1810 +++++++++++++++++++++++++++++ src/win.rs | 672 +++++++++++ 13 files changed, 3323 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 build.rs create mode 100644 examples/README.md create mode 100644 examples/install.toml create mode 100644 examples/payload/post_install.ps1 create mode 100644 examples/payload/sample_app.cmd create mode 100644 project_mvp.md create mode 100644 src/main.rs create mode 100644 src/win.rs diff --git a/.gitignore b/.gitignore index 2b27938..e1ea826 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.target/ \ No newline at end of file +# Added by cargo +/dist* +/target diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1ea717f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Covenant-Setup is a Windows installer engine written in Rust. It deterministically tracks all system mutations (files, directories, registry keys, shortcuts, scripts) via a journaling model, enabling exact rollback on uninstall. Windows-only; all system operations use Win32 APIs directly. + +## Build & Run Commands + +```bash +cargo fmt # Format code +cargo check # Type-check without building +cargo build # Debug build +cargo build --release # Release build + +# Package: bundle manifest + payload into a single-file installer EXE +cargo run -- package examples/install.toml --output dist + +# Install: apply a manifest directly (or from embedded bundle) +cargo run -- install examples/install.toml --json + +# Uninstall: reverse all journaled actions +cargo run -- uninstall examples/journal.json --json +``` + +No automated test suite exists yet. Manual testing uses the example manifest (`examples/install.toml`). + +## Architecture + +**Two source files:** +- `src/main.rs` — CLI (clap derive), manifest parsing, install/uninstall/package logic, journaling, UI (TUI/GUI/JSON), elevation handling +- `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):** +1. `package` — Reads TOML manifest, embeds it + payload files into the EXE binary using an append format (JSON payload + u64 size + magic footer `COVENANT_SETUP_BUNDLE_V1`) +2. `install` — Parses manifest (from file or embedded bundle), executes mutations in order, writes `journal.json`, registers in Add/Remove Programs +3. `uninstall` — Reads `journal.json`, reverses actions in LIFO order, handles locked files via Restart Manager + `MoveFileEx` reboot fallback, spawns cleanup helper for self-deletion + +**Key types:** +- `InstallManifest` — Declarative TOML contract: directories, files, registry, shortcuts, scripts, purge spec +- `Journal` / `JournalAction` — Serialized record of every mutation for deterministic rollback +- `MutationTracker` trait — Extensibility point (MVP uses `DeclaredTracker`; future: `ObservedTracker` for ETW-based capture) +- `PathResolver` — Resolves `{ProgramFilesX64}`, `{LocalAppData}`, `{Desktop}` tokens via `SHGetKnownFolderPath` +- `Logger` — Dual-mode output: structured JSON (`--json` flag) for IPC or human-readable text + +**Elevation:** Manifest/journal is scanned for `HKLM` registry or ProgramFiles paths to determine if admin is needed. Auto-relaunches via `ShellExecuteW` with `runas` when `--elevate` flag is set. Exit code 33 signals elevation required. + +**UI modes:** `--headless` forces TUI, `--headed` forces GUI (PowerShell-hosted WinForms), auto-detected from parent process otherwise. JSON mode (`--json`) is for programmatic consumers. + +## Conventions + +- All Win32 calls go in `src/win.rs`, never in `main.rs` +- 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 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dfafbd3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,465 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "covenant-setup" +version = "0.1.0" +dependencies = [ + "clap", + "embed-manifest", + "serde", + "serde_json", + "thiserror", + "toml", + "windows", +] + +[[package]] +name = "embed-manifest" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94cdc65b1cf9e871453ce2f86f5aaec24ff2eaa36a1fa3e02e441dddc3613b99" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..85cfe5f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "covenant-setup" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.5.39", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +thiserror = "2.0.17" +toml = "0.9.7" +windows = { version = "0.62.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Com", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_RestartManager", + "Win32_System_Registry", + "Win32_System_Threading", + "Win32_UI_Controls", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } + +[build-dependencies] +embed-manifest = "1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6908c6f --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# covenant-setup + +`covenant-setup` is a Windows installer builder and install engine written in Rust. + +## Why a different Windows installer/uninstaller packager? + +Windows has a mess when it comes to managing program lifecycles. Developers can leave files everywhere on install, the OS lets you do ANYTHING if you elevate to admin, and the uninstall process has no idea what files and registry entries were actually created during the install, leaving behind a mess and contributing to registry rot. + +This packager aims to take a different approach by + +- Observing all the places a program installs to during installation and during any post-install scripts/operations and then writing a journal.json to the same directory the application installs to. This file is referenced during uninstall to return the machine back to the state it was before the install with any files and registry entries associated with that program. +- Take a "leave the campground better than you found it" approach - this Eagle Scout practices Leave No Trace. +- Taking a "trust but verify model" to program installs and uninstalls, observing program behavior during install and uninstall in order to respect the user. +- Using the `journal.json` as a manifest of everything the program did during the install and post install process. + +Its current shape is: + +- a packager that takes a developer-authored `install.toml` +- a single-file installer runtime with the app payload embedded into the `.exe` +- an installed uninstaller path that reuses the same Rust engine + +## Current Capabilities + +- Packages an app from a manifest into a single installer executable +- Installs files, directories, registry values, shortcuts, and post-install scripts +- Journals applied mutations to support deterministic uninstall +- Uninstalls in reverse order and purges declared registry/path namespaces +- Registers the installed app in Windows Installed Apps / Add-Remove Programs +- Creates an installed uninstaller executable in the app root +- Uses Win32 APIs through the `windows` crate with unsafe isolated in [`src/win.rs`](C:\Users\jasonross\workspace\covenant-setup\src\win.rs) +- Logs every unsafe boundary transition + +## Packaging Model + +The packager command is: + +```powershell +cargo run -- package path\to\install.toml --output dist +``` + +Current output: + +- `dist\covenant-setup-installer.exe` + +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. + +## Install and Uninstall Model + +Direct engine commands: + +```powershell +cargo run -- install path\to\install.toml +cargo run -- uninstall path\to\journal.json +``` + +Packaged installer behavior: + +- Running the packaged installer with no subcommand performs install +- The installed app gets: + - `journal.json` in the install root + - `covenant-setup-uninstall.exe` in the install root + - an uninstall registry entry under `...\CurrentVersion\Uninstall\...` + +Installed-app uninstall behavior: + +- Windows Installed Apps launches the installed uninstaller executable +- The engine removes payload files first +- A cleanup helper from `%TEMP%` removes the running uninstaller after it exits +- If immediate cleanup is impossible, file removal falls back to delete-on-reboot + +## UI Behavior + +There is now one installer/uninstaller path. UI mode is chosen by context unless explicitly overridden. + +Explicit flags: + +- `--headless`: force TUI +- `--headed`: force GUI + +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 + +### GUI + +Current GUI behavior includes: + +- native message-box prompts for confirmation and completion +- a progress window with: + - progress bar + - current operation text + - scrolling operations log +- reboot prompt when uninstall requires reboot to finish some cleanup + +### TUI + +Current TUI behavior includes: + +- `Installing {app_name}` or `Uninstalling {app_name}` +- animated walking dots from 0 to 5, cycling every 500ms +- final success / reboot-needed text prompts + +## Manifest Scope + +The current manifest supports: + +- `directories` +- `files` +- `registry` +- `shortcuts` +- `scripts` +- `purge` + +The sample manifest lives at [`examples/install.toml`](C:\Users\jasonross\workspace\covenant-setup\examples\install.toml). + +## Architecture Notes + +- 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) +- Journaling currently records declared actions through `DeclaredTracker` +- The implementation is Windows-specific + +## Current Limitations + +- The GUI layer is currently implemented through a PowerShell-hosted WinForms progress window rather than a native Rust GUI framework +- The installer is not yet generating branded/custom themed installer screens +- The manifest schema is still MVP-level and does not cover all production installer concerns +- Script execution logs the script invocation; internal script mutations are not observed beyond declared purge coverage +- The packager currently embeds payload as JSON-appended data; this is functional but not yet optimized for large payloads or tamper-resistance +- No signing, MSI generation, compression, delta updates, or patching pipeline exists yet +- No automated test suite has been added yet for end-to-end installer scenarios + +## Verification Status + +The codebase currently builds and formats successfully with: + +```powershell +cargo fmt +cargo check +``` + +Interactive GUI/TUI flows have been exercised during development, but there is not yet a formal automated integration harness for packaged installer behavior. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f0d8063 --- /dev/null +++ b/build.rs @@ -0,0 +1,6 @@ +use embed_manifest::embed_manifest; + +fn main() { + embed_manifest(embed_manifest::new_manifest("Comctl32")) + .expect("unable to embed application manifest"); +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..9d65bb7 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,42 @@ +# Covenant-Setup Smoke Test + +This example stays in `HKCU` and `{LocalAppData}` so it can be exercised without elevation. + +Build single-file installers: + +```powershell +cargo run -- package examples/install.toml --output dist +``` + +This emits: + +- `dist\covenant-setup-installer.exe` + +The generated installer is a single executable with the manifest and payload embedded into it. +It chooses GUI or TUI mode from context, or you can force one explicitly with `--headed` or `--headless`. + +Run install: + +```powershell +cargo run -- install examples/install.toml --json +``` + +Write the journal somewhere explicit: + +```powershell +cargo run -- install examples/install.toml --journal examples/journal.json +``` + +Run uninstall: + +```powershell +cargo run -- uninstall examples/journal.json --json +``` + +Expected effects: + +- Creates `%LOCALAPPDATA%\CovenantSetupExample` +- Copies `sample_app.cmd` into the `bin` directory +- Writes `HKCU\Software\CovenantSetupExample\InstallRoot` +- Creates a desktop shortcut +- Runs an inline PowerShell post-install command and records only the script execution in the journal diff --git a/examples/install.toml b/examples/install.toml new file mode 100644 index 0000000..8765694 --- /dev/null +++ b/examples/install.toml @@ -0,0 +1,35 @@ +app_name = "Covenant-Setup Sample App" + +[[directories]] +path = "{LocalAppData}\\CovenantSetupSample" + +[[directories]] +path = "{LocalAppData}\\CovenantSetupSample\\bin" + +[[files]] +source = "payload\\sample_app.cmd" +destination = "{LocalAppData}\\CovenantSetupSample\\bin\\sample_app.cmd" + +[[registry]] +key = "HKCU\\Software\\CovenantSetupSample" +name = "InstallRoot" +value = "{LocalAppData}\\CovenantSetupSample" + +[[shortcuts]] +path = "{Desktop}\\Covenant-Setup Sample App.lnk" +target = "{LocalAppData}\\CovenantSetupSample\\bin\\sample_app.cmd" +description = "Launch the Covenant-Setup sample payload" + +[[scripts]] +command = "powershell" +args = [ + "-ExecutionPolicy", + "Bypass", + "-Command", + "New-Item -ItemType Directory -Path .\\logs -Force | Out-Null; 'post-install script ran' | Set-Content .\\logs\\post_install.txt" +] +working_directory = "{LocalAppData}\\CovenantSetupSample" + +[purge] +registry_branches = ["HKCU\\Software\\CovenantSetupSample"] +paths = ["{LocalAppData}\\CovenantSetupSample"] diff --git a/examples/payload/post_install.ps1 b/examples/payload/post_install.ps1 new file mode 100644 index 0000000..5e8ea62 --- /dev/null +++ b/examples/payload/post_install.ps1 @@ -0,0 +1,3 @@ +$logDir = Join-Path $PWD "logs" +New-Item -ItemType Directory -Path $logDir -Force | Out-Null +"post-install script ran at $(Get-Date -Format o)" | Set-Content -Path (Join-Path $logDir "post_install.txt") diff --git a/examples/payload/sample_app.cmd b/examples/payload/sample_app.cmd new file mode 100644 index 0000000..07b2c5d --- /dev/null +++ b/examples/payload/sample_app.cmd @@ -0,0 +1,3 @@ +@echo off +echo GlassBox sample app executed. +pause diff --git a/project_mvp.md b/project_mvp.md new file mode 100644 index 0000000..21c5ca5 --- /dev/null +++ b/project_mvp.md @@ -0,0 +1,55 @@ +## Project Overview: The "Glass Box" Core Engine (CLI) +**Objective:** Build a native Windows CLI installation packager in Rust that enforces a deterministic, declarative, and fully reversible state model. + +**Architecture:** A standalone, high-performance Win64 command-line tool. It reads a declarative manifest, performs system mutations via the Win32 API, and journals every action. It is designed to output structured JSON so a GUI wrapper (like C#) or a CI/CD pipeline can orchestrate it in the future. + +--- + +## MVP Requirements & Feature List + +### 1. The Rust CLI Interface & IPC Readiness +* **CLI Framework:** Utilize `clap` for robust argument parsing with standard subcommands (e.g., `glassbox install manifest.toml`, `glassbox uninstall journal.json`). +* **Structured Output Protocol:** The engine must accept a `--json` flag. When active, all standard text logs, progress percentages, and error stack traces must be suppressed and replaced with single-line serialized JSON objects emitted to `stdout`. +* **UAC Handling:** The CLI must detect if it has administrative privileges via token inspection. If elevation is required for target paths, it must gracefully exit with a specific error code or auto-relaunch itself using the `runas` verb. + +### 2. Execution & State Management +* **Declarative Contract Parsing:** The engine ingests an `install.toml` manifest defining the exact expected system state (directories to create, binaries to move, registry keys to write, shortcuts to build). +* **API Adherence:** All system calls must utilize the `windows` crate, strictly employing UTF-16 Wide (`W`) Win32 functions. +* **Registry Architecture:** Registry operations must explicitly use the `KEY_WOW64_64KEY` flag to bypass 32-bit redirection, ensuring true 64-bit state management. +* **Dynamic Path Resolution:** Hardcoded paths are forbidden. The engine must use `SHGetKnownFolderPath` (Shell32) to resolve standard directories like `ProgramFilesX64`, `LocalAppData`, and `Desktop`. + +### 3. Modular Mutation Tracking (Extensibility Architecture) +* **The `MutationTracker` Trait:** Internal state changes must not be written directly to the journal. Instead, they pass through a Trait/Interface. +* **MVP Implementation:** The initial implementation will be a `DeclaredTracker`. It strictly records the actions the engine performs based on the `install.toml` manifest. +* **Future-Proofing:** This trait design allows an `ObservedTracker` (the ETW Watchdog) to be cleanly injected later to capture out-of-bounds actions performed by sub-processes without changing the core engine logic. +* **Script Execution:** The engine can execute procedural post-install scripts (e.g., PowerShell) via `std::process::Command`, but in the MVP, it will only log the *execution* of the script, not the script's internal mutations. + +### 4. Journaling and Uninstallation (Deterministic Rollback) +* **The Transaction Journal:** The engine's applied mutations must be written to a local `journal.json` or `journal.toml` file in the application's root directory upon successful installation. +* **Reverse Execution:** The uninstaller sequence must parse the journal and execute deletion operations in strict reverse chronological order. +* **Locked File Handling:** If a binary is locked by a running process during uninstallation, the engine must leverage the Restart Manager API (`RmStartSession`, `RmGetList`) to identify the locking process, or fallback to `MoveFileEx` with the `MOVEFILE_DELAY_UNTIL_REBOOT` flag. +* **Namespace Purging:** The uninstaller must aggressively delete the entirety of the developer's defined configuration branches (e.g., `HKCU\Software\TargetApp` and `%LOCALAPPDATA%\TargetApp`) to ensure zero shadow residue. + +--- + +## Technical Documentation & Reference Links + +These references cover the specific Win32 API boundaries and Rust bindings required for the MVP. + +### Rust & Integration Crates +* **`windows` Crate:** The official Microsoft language projection for Win32 APIs. Essential for low-level system access. + * *Documentation:* [https://microsoft.github.io/windows-docs-rs/](https://microsoft.github.io/windows-docs-rs/) +* **`clap` Crate:** The standard for building robust CLI interfaces in Rust. + * *Documentation:* [https://docs.rs/clap/latest/clap/](https://docs.rs/clap/latest/clap/) +* **`serde` & `serde_json` Crates:** For parsing the `install.toml` and formatting the IPC `stdout` streams. + * *Documentation:* [https://serde.rs/](https://serde.rs/) + +### Windows System APIs +* **The Windows Registry:** Understanding hives, keys, values, and x64 redirection behavior. + * *Documentation:* [Structure of the Registry - Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/sysinfo/structure-of-the-registry) +* **Restart Manager API:** Necessary for querying which processes are locking files during uninstallation. + * *Documentation:* [Restart Manager - Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/rstmgr/restart-manager-portal) +* **Known Folders (Shell32):** Standardizing where application data is written to avoid hardcoded paths. + * *Documentation:* [KNOWNFOLDERID - Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid) +* **File Management (MoveFileEx):** Crucial for handling delayed deletions upon reboot. + * *Documentation:* [MoveFileExW function - Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw) \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..53c4409 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1810 @@ +#![windows_subsystem = "windows"] +mod win; + +use clap::{ArgAction, Parser, Subcommand}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::ffi::OsString; +use std::fmt::Display; +use std::fs; +use std::io; +use std::io::IsTerminal; +use std::io::{Read, Write}; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{self, Command}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::thread; +use std::time::Duration; +use thiserror::Error; + +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; + +#[derive(Parser, Debug)] +#[command( + name = "covenant-setup", + version, + about = "Windows installer builder and engine" +)] +struct Cli { + #[arg(long, global = true, action = ArgAction::SetTrue)] + json: bool, + #[arg(long, global = true, action = ArgAction::SetTrue)] + headless: bool, + #[arg(long, global = true, action = ArgAction::SetTrue, conflicts_with = "headless")] + headed: bool, + #[arg(long, global = true, action = ArgAction::SetTrue)] + elevate: bool, + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + Package { + manifest: PathBuf, + #[arg(long, default_value = "dist")] + output: PathBuf, + }, + Install { + manifest: PathBuf, + #[arg(long)] + journal: Option, + }, + Uninstall { + journal: PathBuf, + }, + #[command(hide = true)] + Cleanup { + #[arg(long)] + target_exe: PathBuf, + #[arg(long)] + install_root: Option, + #[arg(long)] + app_name: String, + }, +} + +#[derive(Debug, Deserialize)] +struct InstallManifest { + app_name: String, + #[serde(default)] + directories: Vec, + #[serde(default)] + files: Vec, + #[serde(default)] + registry: Vec, + #[serde(default)] + shortcuts: Vec, + #[serde(default)] + scripts: Vec, + #[serde(default)] + purge: PurgeSpec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct PurgeSpec { + #[serde(default)] + registry_branches: Vec, + #[serde(default)] + paths: Vec, +} + +#[derive(Debug, Deserialize)] +struct DirectorySpec { + path: String, +} + +#[derive(Debug, Deserialize)] +struct FileSpec { + source: String, + destination: String, +} + +#[derive(Debug, Deserialize)] +struct RegistrySpec { + key: String, + name: String, + value: String, +} + +#[derive(Debug, Deserialize)] +struct ShortcutSpec { + path: String, + target: String, + #[serde(default)] + arguments: Option, + #[serde(default)] + working_directory: Option, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Deserialize)] +struct ScriptSpec { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + working_directory: Option, +} + +#[derive(Debug, Clone)] +struct InstallRuntime { + journal_path: PathBuf, + install_root: Option, + uninstall_exe_path: Option, + uninstall_registry_root: RegistryRoot, + uninstall_registry_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Journal { + app_name: String, + manifest_path: Option, + actions: Vec, + purge: PurgeSpec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum JournalAction { + CreateDirectory { + path: PathBuf, + }, + CopyFile { + source: PathBuf, + destination: PathBuf, + }, + WriteRegistry { + root: RegistryRoot, + subkey: String, + name: String, + }, + CreateShortcut { + path: PathBuf, + }, + ExecuteScript { + command: String, + args: Vec, + working_directory: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PackagedApp { + app_name: String, + manifest: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct EmbeddedFile { + relative_path: String, + data: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct EmbeddedBundle { + metadata: PackagedApp, + files: Vec, +} + +trait MutationTracker { + fn record(&mut self, action: JournalAction); + fn finish(self, app_name: String, manifest_path: Option, purge: PurgeSpec) -> Journal; +} + +struct DeclaredTracker { + actions: Vec, +} + +impl DeclaredTracker { + fn new() -> Self { + Self { + actions: Vec::new(), + } + } +} + +impl MutationTracker for DeclaredTracker { + fn record(&mut self, action: JournalAction) { + self.actions.push(action); + } + + fn finish(self, app_name: String, manifest_path: Option, purge: PurgeSpec) -> Journal { + Journal { + app_name, + manifest_path, + actions: self.actions, + purge, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum RegistryRoot { + Hkcu, + Hklm, +} + +#[derive(Debug, Error)] +enum AppError { + #[error("{0}")] + Message(String), + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Toml(#[from] toml::de::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error(transparent)] + Windows(#[from] windows::core::Error), +} + +#[derive(Clone)] +struct Logger { + json: bool, + quiet: bool, +} + +impl Logger { + fn info(&self, event: &str, detail: impl Serialize) { + if self.quiet { + return; + } + if self.json { + println!("{}", json!({"type":"event","event":event,"detail":detail})); + } else { + println!( + "{event}: {}", + serde_json::to_string(&detail).unwrap_or_default() + ); + } + } + + fn unsafe_enter(&self, operation: &str, detail: impl Serialize) { + self.info( + "unsafe_enter", + json!({"operation":operation,"detail":detail}), + ); + } + + fn unsafe_exit(&self, operation: &str, detail: impl Serialize) { + self.info( + "unsafe_exit", + json!({"operation":operation,"detail":detail}), + ); + } + + fn result(&self, status: &str, detail: impl Serialize) { + if self.quiet { + return; + } + if self.json { + println!( + "{}", + json!({"type":"result","status":status,"detail":detail}) + ); + } else { + println!( + "{status}: {}", + serde_json::to_string(&detail).unwrap_or_default() + ); + } + } + + fn error(&self, message: impl Display, code: i32) { + if self.json { + println!( + "{}", + json!({"type":"error","code":code,"message":message.to_string()}) + ); + } else { + eprintln!("error[{code}]: {message}"); + } + } + + fn quiet_clone(&self) -> Self { + Self { + json: self.json, + quiet: true, + } + } +} + +enum RuntimeMode { + Bundled, +} + +#[derive(Clone, Copy)] +struct UiPreferences { + headless: bool, + headed: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum UiMode { + None, + Gui, + Tui, +} + +#[derive(Clone, Copy)] +enum UiPhase { + Install, + Uninstall, + Cleanup, +} + +struct TuiProgress { + active: Arc, + handle: Option>, +} + +impl TuiProgress { + fn start(label: String) -> Self { + let active = Arc::new(AtomicBool::new(true)); + let active_thread = active.clone(); + let handle = thread::spawn(move || { + let frames = ["", ".", "..", "...", "....", "....."]; + let mut index = 0usize; + while active_thread.load(Ordering::Relaxed) { + let frame = frames[index % frames.len()]; + print!("\r{label}{frame} "); + let _ = io::stdout().flush(); + thread::sleep(Duration::from_millis(500)); + index = (index + 1) % frames.len(); + } + print!("\r{}\r", " ".repeat(label.len() + 8)); + let _ = io::stdout().flush(); + }); + Self { + active, + handle: Some(handle), + } + } +} + +impl Drop for TuiProgress { + fn drop(&mut self) { + self.active.store(false, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +struct GuiProgress { + state_path: PathBuf, + log_path: PathBuf, + total_steps: usize, +} + +impl GuiProgress { + fn start(title: &str, initial_message: &str, total_steps: usize) -> Result { + let root = std::env::temp_dir().join("covenant-setup-ui"); + fs::create_dir_all(&root)?; + let stamp = unique_ticks(); + let state_path = root.join(format!("state-{stamp}.json")); + let log_path = root.join(format!("log-{stamp}.txt")); + fs::write(&log_path, b"")?; + write_progress_state(&state_path, title, initial_message, 0, total_steps, false)?; + spawn_gui_progress_window(&state_path, &log_path, title)?; + Ok(Self { + state_path, + log_path, + total_steps, + }) + } + + fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError> { + append_progress_log(&self.log_path, message)?; + write_progress_state( + &self.state_path, + "", + message, + current_step, + self.total_steps, + false, + )?; + Ok(()) + } + + fn finish(&mut self, message: &str) -> Result<(), AppError> { + write_progress_state( + &self.state_path, + "", + message, + self.total_steps, + self.total_steps, + true, + )?; + Ok(()) + } +} + +impl Drop for GuiProgress { + fn drop(&mut self) { + let _ = write_progress_state( + &self.state_path, + "", + "Complete", + self.total_steps, + self.total_steps, + true, + ); + } +} + +fn main() { + let args: Vec<_> = std::env::args_os().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 _ = win::gui_report_error(&err.to_string(), &logger); + 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) { + Ok(()) => 0, + Err(AppError::Message(message)) if message == "__elevated_relaunch__" => 0, + Err(err) => { + let code = if matches!(&err, AppError::Message(message) if message.contains("Elevation required")) + { + EXIT_ELEVATION_REQUIRED + } else { + EXIT_OPERATION_FAILED + }; + logger.error(err, code); + code + } + }; + process::exit(exit_code); +} + +fn run(cli: Cli, logger: &Logger) -> Result<(), AppError> { + let preferences = ui_preferences_from_cli(&cli); + match cli.command { + Commands::Package { manifest, output } => package(&manifest, &output, logger), + Commands::Install { manifest, journal } => install( + &manifest, + journal, + cli.elevate, + select_ui(UiPhase::Install, preferences, logger)?, + logger, + ), + Commands::Uninstall { journal } => uninstall( + &journal, + cli.elevate, + select_ui(UiPhase::Uninstall, preferences, logger)?, + logger, + ), + Commands::Cleanup { + target_exe, + install_root, + app_name, + } => cleanup( + target_exe, + install_root, + app_name, + select_ui(UiPhase::Cleanup, preferences, logger)?, + logger, + ), + } +} + +fn package(manifest_path: &Path, output_root: &Path, logger: &Logger) -> Result<(), AppError> { + let manifest: InstallManifest = toml::from_str(&fs::read_to_string(manifest_path)?)?; + let current_exe = std::env::current_exe()?; + let manifest_dir = manifest_path + .parent() + .ok_or_else(|| AppError::Message("Manifest must have a parent directory".into()))?; + fs::create_dir_all(output_root)?; + let installer_target = output_root.join("covenant-setup-installer.exe"); + build_packaged_installer( + &installer_target, + ¤t_exe, + manifest_dir, + manifest_path, + &manifest, + logger, + )?; + + logger.result( + "ok", + json!({ + "installer": installer_target + }), + ); + Ok(()) +} + +fn build_packaged_installer( + exe_target: &Path, + current_exe: &Path, + manifest_dir: &Path, + manifest_path: &Path, + manifest: &InstallManifest, + logger: &Logger, +) -> Result<(), AppError> { + fs::copy(current_exe, &exe_target)?; + let bundle = EmbeddedBundle { + metadata: PackagedApp { + app_name: manifest.app_name.clone(), + manifest: BUNDLE_MANIFEST.to_string(), + }, + files: collect_bundle_files(manifest_dir, manifest_path)?, + }; + append_embedded_bundle(exe_target, &bundle)?; + logger.info( + "package_artifact", + json!({ + "exe": exe_target, + "embedded_files": bundle.files.len() + }), + ); + Ok(()) +} + +fn collect_bundle_files( + source_root: &Path, + manifest_path: &Path, +) -> Result, AppError> { + let mut files = Vec::new(); + collect_bundle_files_recursive(source_root, source_root, manifest_path, &mut files)?; + Ok(files) +} + +fn collect_bundle_files_recursive( + source_root: &Path, + current: &Path, + manifest_path: &Path, + files: &mut Vec, +) -> Result<(), AppError> { + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_bundle_files_recursive(source_root, &path, manifest_path, 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 { + relative.to_string_lossy().to_string() + }; + files.push(EmbeddedFile { + relative_path, + data: fs::read(&path)?, + }); + } + } + Ok(()) +} + +fn append_embedded_bundle(exe_target: &Path, bundle: &EmbeddedBundle) -> Result<(), AppError> { + let payload = serde_json::to_vec(bundle)?; + let mut file = fs::OpenOptions::new().append(true).open(exe_target)?; + file.write_all(&payload)?; + file.write_all(&(payload.len() as u64).to_le_bytes())?; + file.write_all(EMBEDDED_MAGIC)?; + Ok(()) +} + +fn read_embedded_bundle(exe_path: &Path) -> Result, AppError> { + let mut file = fs::File::open(exe_path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + let footer_len = EMBEDDED_MAGIC.len() + std::mem::size_of::(); + if bytes.len() < footer_len { + return Ok(None); + } + let magic_offset = bytes.len() - EMBEDDED_MAGIC.len(); + if &bytes[magic_offset..] != EMBEDDED_MAGIC { + return Ok(None); + } + let size_offset = magic_offset - std::mem::size_of::(); + let payload_len = u64::from_le_bytes( + bytes[size_offset..magic_offset] + .try_into() + .map_err(|_| AppError::Message("Invalid embedded payload footer".into()))?, + ) as usize; + if size_offset < payload_len { + return Err(AppError::Message( + "Embedded payload length exceeds executable size".into(), + )); + } + let payload_offset = size_offset - payload_len; + let bundle: EmbeddedBundle = serde_json::from_slice(&bytes[payload_offset..size_offset])?; + Ok(Some(bundle)) +} + +fn extract_embedded_bundle(exe_path: &Path, bundle: &EmbeddedBundle) -> Result { + let temp_root = std::env::temp_dir().join("covenant-setup").join(format!( + "{}-{}", + exe_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .replace(' ', "_"), + process::id() + )); + if temp_root.exists() { + fs::remove_dir_all(&temp_root)?; + } + fs::create_dir_all(&temp_root)?; + for file in &bundle.files { + let target = temp_root.join(&file.relative_path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::write(target, &file.data)?; + } + Ok(temp_root) +} + +fn detect_runtime_mode() -> Option { + let exe = std::env::current_exe().ok()?; + if read_embedded_bundle(&exe).ok().flatten().is_none() { + return None; + } + Some(RuntimeMode::Bundled) +} + +fn run_bundled_installer( + mode: RuntimeMode, + preferences: UiPreferences, + logger: &Logger, +) -> Result<(), AppError> { + let exe = std::env::current_exe()?; + let bundle = read_embedded_bundle(&exe)? + .ok_or_else(|| AppError::Message("No embedded package found in installer".into()))?; + let extraction_root = extract_embedded_bundle(&exe, &bundle)?; + let metadata = bundle.metadata; + let manifest_path = extraction_root.join(metadata.manifest.clone()); + let journal_path = exe + .parent() + .ok_or_else(|| AppError::Message("Packaged installer has no parent directory".into()))? + .join("journal.json"); + + match mode { + RuntimeMode::Bundled => { + let ui_mode = select_ui(UiPhase::Install, preferences, logger)?; + if ui_mode == UiMode::Gui && !win::gui_confirm_install(&metadata.app_name, logger)? { + return Ok(()); + } + match install(&manifest_path, Some(journal_path), true, ui_mode, logger) { + Ok(()) => { + if ui_mode == UiMode::Gui { + win::gui_report_success(&metadata.app_name, logger)?; + } + Ok(()) + } + Err(err) => { + if ui_mode == UiMode::Gui { + win::gui_report_error(&err.to_string(), logger)?; + } + Err(err) + } + } + } + } +} + +fn install( + manifest_path: &Path, + journal_path: Option, + elevate: bool, + ui_mode: UiMode, + logger: &Logger, +) -> Result<(), AppError> { + let manifest: InstallManifest = toml::from_str(&fs::read_to_string(manifest_path)?)?; + 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 result = (|| -> Result<(), AppError> { + let effective_logger = if ui_mode == UiMode::Tui { + logger.quiet_clone() + } else { + logger.clone() + }; + let resolver = win::PathResolver::new(&effective_logger)?; + let requires_admin = manifest_requires_admin(&manifest, &resolver)?; + ensure_elevation_if_needed(requires_admin, elevate, &effective_logger)?; + let runtime = build_install_runtime( + &manifest, + manifest_path, + journal_path, + requires_admin, + &resolver, + )?; + let mut tracker = DeclaredTracker::new(); + + let mut progress_step = 0usize; + 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( + &mut gui_progress, + progress_step, + &format!("Creating directory {}", path.display()), + )?; + win::create_directory_recursive(&path, &effective_logger)?; + tracker.record(JournalAction::CreateDirectory { path }); + } + + for file in &manifest.files { + let source = absolutize(manifest_path.parent(), &file.source); + let destination = resolver.resolve(&file.destination); + if let Some(parent) = destination.parent() { + win::create_directory_recursive(parent, &effective_logger)?; + } + effective_logger.info( + "copy_file", + json!({"source":source,"destination":destination}), + ); + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Copying file to {}", destination.display()), + )?; + win::copy_file(&source, &destination, &effective_logger)?; + tracker.record(JournalAction::CopyFile { + source, + destination, + }); + } + + for entry in &manifest.registry { + let (root, subkey) = parse_registry_key(&entry.key)?; + let resolved_value = resolver.resolve(&entry.value).to_string_lossy().to_string(); + effective_logger.info( + "write_registry", + json!({"key":entry.key,"name":entry.name,"value":resolved_value}), + ); + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Writing registry value {} in {}", entry.name, entry.key), + )?; + win::set_registry_string( + root, + &subkey, + &entry.name, + &resolved_value, + &effective_logger, + )?; + tracker.record(JournalAction::WriteRegistry { + root, + subkey, + name: entry.name.clone(), + }); + } + + for shortcut in &manifest.shortcuts { + let path = resolver.resolve(&shortcut.path); + let target = resolver.resolve(&shortcut.target); + let working_directory = shortcut + .working_directory + .as_deref() + .map(|v| resolver.resolve(v)); + if let Some(parent) = path.parent() { + win::create_directory_recursive(parent, &effective_logger)?; + } + effective_logger.info("create_shortcut", json!({"path":path,"target":target})); + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Creating shortcut {}", path.display()), + )?; + win::create_shortcut( + &path, + &target, + shortcut.arguments.as_deref(), + working_directory.as_deref(), + shortcut.description.as_deref(), + &effective_logger, + )?; + tracker.record(JournalAction::CreateShortcut { path }); + } + + for script in &manifest.scripts { + let working_directory = script + .working_directory + .as_deref() + .map(|v| resolver.resolve(v)); + effective_logger.info("execute_script", json!({"command":script.command,"args":script.args,"working_directory":working_directory})); + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Running script {}", script.command), + )?; + execute_script( + script, + manifest_path.parent(), + working_directory.as_deref(), + &mut gui_progress, + )?; + tracker.record(JournalAction::ExecuteScript { + command: script.command.clone(), + args: script.args.clone(), + working_directory, + }); + } + + if let Some(uninstall_exe_path) = &runtime.uninstall_exe_path { + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Installing uninstaller {}", uninstall_exe_path.display()), + )?; + install_uninstaller(uninstall_exe_path, &effective_logger)?; + tracker.record(JournalAction::CopyFile { + source: std::env::current_exe()?, + destination: uninstall_exe_path.clone(), + }); + } + + if let (Some(install_root), Some(uninstall_exe_path)) = + (&runtime.install_root, &runtime.uninstall_exe_path) + { + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Registering {} in Installed Apps", manifest.app_name), + )?; + register_uninstall_entry( + &manifest, + &runtime, + install_root, + uninstall_exe_path, + &effective_logger, + )?; + for value_name in [ + "DisplayName", + "Publisher", + "DisplayVersion", + "InstallLocation", + "DisplayIcon", + "UninstallString", + "QuietUninstallString", + ] { + tracker.record(JournalAction::WriteRegistry { + root: runtime.uninstall_registry_root, + subkey: runtime.uninstall_registry_key.clone(), + name: value_name.to_string(), + }); + } + } + + let journal = tracker.finish( + manifest.app_name.clone(), + Some(manifest_path.to_path_buf()), + manifest.purge, + ); + if let Some(parent) = runtime.journal_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&runtime.journal_path, serde_json::to_vec_pretty(&journal)?)?; + effective_logger.result( + "ok", + json!({"journal":runtime.journal_path,"actions":journal.actions.len()}), + ); + finish_gui_progress( + &mut gui_progress, + &format!("{} installation completed successfully", manifest.app_name), + )?; + if ui_mode == UiMode::Tui { + println!("{} installation completed successfully", manifest.app_name); + } + Ok(()) + })(); + + if let Err(err) = &result { + let _ = fail_gui_progress( + &mut gui_progress, + &format!("{app_name} installation failed: {err}"), + ); + } + + result +} + +fn uninstall( + journal_path: &Path, + elevate: bool, + ui_mode: UiMode, + logger: &Logger, +) -> Result<(), AppError> { + let journal: Journal = serde_json::from_str(&fs::read_to_string(journal_path)?)?; + 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 result = (|| -> Result<(), AppError> { + let effective_logger = if ui_mode == UiMode::Tui { + logger.quiet_clone() + } else { + logger.clone() + }; + let resolver = win::PathResolver::new(&effective_logger)?; + let requires_admin = journal_requires_admin(&journal, &resolver)?; + ensure_elevation_if_needed(requires_admin, elevate, &effective_logger)?; + let current_exe = std::env::current_exe().ok(); + let mut deferred_self_delete: Option = None; + let mut deferred_uninstall_registry: Vec<(RegistryRoot, String)> = Vec::new(); + let mut progress_step = 0usize; + + for action in journal.actions.iter().rev() { + match action { + JournalAction::CreateDirectory { path } => { + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Removing directory {}", path.display()), + )?; + win::remove_directory_if_exists(path, &effective_logger)? + } + JournalAction::CopyFile { destination, .. } => { + if current_exe + .as_ref() + .is_some_and(|exe| same_path(exe, destination)) + { + effective_logger.info("defer_self_delete", json!({"path":destination})); + deferred_self_delete = Some(destination.clone()); + } else { + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Removing file {}", destination.display()), + )?; + win::remove_file_with_fallback(destination, &effective_logger)? + } + } + JournalAction::WriteRegistry { root, subkey, .. } => { + if is_uninstall_registry_key(subkey) { + deferred_uninstall_registry.push((*root, subkey.clone())); + } else { + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Removing registry branch {}", subkey), + )?; + win::delete_registry_tree(*root, subkey, &effective_logger)? + } + } + JournalAction::CreateShortcut { path } => { + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Removing shortcut {}", path.display()), + )?; + win::remove_file_with_fallback(path, &effective_logger)? + } + JournalAction::ExecuteScript { .. } => { + effective_logger.info("skip_script_rollback", json!({})) + } + } + } + + for branch in &journal.purge.registry_branches { + let (root, subkey) = parse_registry_key(branch)?; + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Purging registry branch {}", branch), + )?; + win::delete_registry_tree(root, &subkey, &effective_logger)?; + } + for path in &journal.purge.paths { + progress_step += 1; + let resolved = resolver.resolve(path); + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Purging path {}", resolved.display()), + )?; + purge_path(&resolved, &effective_logger)?; + } + + for (root, subkey) in deferred_uninstall_registry { + progress_step += 1; + advance_gui_progress( + &mut gui_progress, + progress_step, + &format!("Removing uninstall registration {}", subkey), + )?; + win::delete_registry_tree(root, &subkey, &effective_logger)?; + } + + if let Some(path) = deferred_self_delete { + finish_gui_progress( + &mut gui_progress, + &format!("Finalizing removal of {}", journal.app_name), + )?; + spawn_cleanup_helper( + &path, + path.parent(), + &journal.app_name, + ui_mode, + &effective_logger, + )?; + } else { + finish_gui_progress( + &mut gui_progress, + &format!("{} uninstalled successfully!", journal.app_name), + )?; + if ui_mode == UiMode::Gui { + win::gui_report_uninstall_success(&journal.app_name, &effective_logger)?; + } + } + + effective_logger.result("ok", json!({"journal":journal_path})); + Ok(()) + })(); + + if let Err(err) = &result { + let _ = fail_gui_progress( + &mut gui_progress, + &format!("{app_name} uninstall failed: {err}"), + ); + } + + result +} + +fn cleanup( + target_exe: PathBuf, + install_root: Option, + app_name: String, + ui_mode: UiMode, + logger: &Logger, +) -> Result<(), AppError> { + let effective_logger = if ui_mode == UiMode::Tui { + logger.quiet_clone() + } else { + logger.clone() + }; + let mut reboot_required = false; + for _ in 0..50 { + if !target_exe.exists() { + break; + } + if fs::remove_file(&target_exe).is_ok() { + break; + } + thread::sleep(Duration::from_millis(200)); + } + if target_exe.exists() { + win::remove_file_with_fallback(&target_exe, &effective_logger)?; + reboot_required = target_exe.exists(); + } + if let Some(install_root) = install_root { + if install_root.exists() && fs::read_dir(&install_root)?.next().is_none() { + win::remove_directory_if_exists(&install_root, &effective_logger)?; + } + } + reboot_required |= schedule_helper_self_cleanup(&effective_logger)?; + if ui_mode == UiMode::Gui { + if reboot_required { + if win::gui_prompt_uninstall_reboot(&app_name, &effective_logger)? { + spawn_reboot(&effective_logger)?; + } + } else { + win::gui_report_uninstall_success(&app_name, &effective_logger)?; + } + } 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." + ); + if prompt_reboot_tui()? { + spawn_reboot(&effective_logger)?; + } + } else { + println!("{app_name} uninstalled successfully!"); + } + } + Ok(()) +} + +fn ensure_elevation_if_needed( + required: bool, + relaunch: bool, + logger: &Logger, +) -> Result<(), AppError> { + if !required || win::is_elevated(logger)? { + return Ok(()); + } + if relaunch { + win::relaunch_as_admin(logger)?; + return Err(AppError::Message("__elevated_relaunch__".into())); + } + Err(AppError::Message( + "Elevation required for requested operation".into(), + )) +} + +fn build_install_runtime( + manifest: &InstallManifest, + manifest_path: &Path, + journal_path: Option, + requires_admin: bool, + resolver: &win::PathResolver, +) -> Result { + let install_root = infer_install_root(manifest, resolver); + let journal_path = journal_path.unwrap_or_else(|| { + install_root + .clone() + .unwrap_or_else(|| { + manifest_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf() + }) + .join("journal.json") + }); + let uninstall_exe_path = install_root + .clone() + .map(|root| root.join("covenant-setup-uninstall.exe")); + let uninstall_registry_root = if requires_admin { + RegistryRoot::Hklm + } else { + RegistryRoot::Hkcu + }; + let uninstall_registry_key = format!( + "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}", + sanitize_registry_component(&manifest.app_name) + ); + Ok(InstallRuntime { + journal_path, + install_root, + uninstall_exe_path, + uninstall_registry_root, + uninstall_registry_key, + }) +} + +fn infer_install_root(manifest: &InstallManifest, resolver: &win::PathResolver) -> Option { + if let Some(path) = manifest.purge.paths.first() { + return Some(resolver.resolve(path)); + } + if let Some(directory) = manifest.directories.first() { + return Some(resolver.resolve(&directory.path)); + } + if let Some(file) = manifest.files.first() { + return resolver + .resolve(&file.destination) + .parent() + .map(Path::to_path_buf); + } + None +} + +fn install_uninstaller(uninstall_exe_path: &Path, logger: &Logger) -> Result<(), AppError> { + if let Some(parent) = uninstall_exe_path.parent() { + fs::create_dir_all(parent)?; + } + let current_exe = std::env::current_exe()?; + logger.info( + "install_uninstaller", + json!({"source":current_exe,"destination":uninstall_exe_path}), + ); + fs::copy(current_exe, uninstall_exe_path)?; + Ok(()) +} + +fn register_uninstall_entry( + manifest: &InstallManifest, + runtime: &InstallRuntime, + install_root: &Path, + uninstall_exe_path: &Path, + logger: &Logger, +) -> Result<(), AppError> { + let uninstall_command = format!( + "\"{}\" uninstall \"{}\" --elevate", + uninstall_exe_path.display(), + runtime.journal_path.display() + ); + let values = [ + ("DisplayName", manifest.app_name.clone()), + ("Publisher", "covenant-setup".to_string()), + ("DisplayVersion", env!("CARGO_PKG_VERSION").to_string()), + ( + "InstallLocation", + install_root.to_string_lossy().to_string(), + ), + ( + "DisplayIcon", + uninstall_exe_path.to_string_lossy().to_string(), + ), + ("UninstallString", uninstall_command.clone()), + ("QuietUninstallString", uninstall_command), + ]; + for (name, value) in values { + logger.info( + "register_uninstall_value", + json!({"key":runtime.uninstall_registry_key,"name":name,"value":value}), + ); + win::set_registry_string( + runtime.uninstall_registry_root, + &runtime.uninstall_registry_key, + name, + &value, + logger, + )?; + } + Ok(()) +} + +fn spawn_cleanup_helper( + target_exe: &Path, + install_root: Option<&Path>, + app_name: &str, + ui_mode: UiMode, + logger: &Logger, +) -> Result<(), AppError> { + let current_exe = std::env::current_exe()?; + let helper_path = std::env::temp_dir().join(format!( + "covenant-setup-cleanup-{}-{}.exe", + process::id(), + unique_ticks() + )); + logger.info( + "spawn_cleanup_helper", + json!({"helper":helper_path,"target_exe":target_exe,"install_root":install_root}), + ); + fs::copy(¤t_exe, &helper_path)?; + + let mut command = Command::new(&helper_path); + command.creation_flags(CREATE_NO_WINDOW); + if ui_mode == UiMode::Tui { + command.arg("--headless"); + } + command.arg("cleanup"); + command.arg("--target-exe"); + command.arg(target_exe); + if let Some(install_root) = install_root { + command.arg("--install-root"); + command.arg(install_root); + } + command.arg("--app-name"); + command.arg(app_name); + command.spawn()?; + Ok(()) +} + +fn start_tui_progress(ui_mode: UiMode, label: String) -> Option { + if ui_mode == UiMode::Tui { + Some(TuiProgress::start(label)) + } else { + None + } +} + +fn parse_ui_preferences(args: &[OsString]) -> UiPreferences { + let mut preferences = UiPreferences { + headless: false, + headed: 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; + } + } + preferences +} + +fn ui_preferences_from_cli(cli: &Cli) -> UiPreferences { + UiPreferences { + headless: cli.headless, + headed: cli.headed, + } +} + +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, + logger: &Logger, +) -> Result { + if preferences.headless { + return Ok(UiMode::Tui); + } + if preferences.headed { + 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, + }) +} + +fn start_gui_progress( + ui_mode: UiMode, + title: &str, + app_name: &str, + total_steps: usize, +) -> Result, AppError> { + if ui_mode == UiMode::Gui { + Ok(Some(GuiProgress::start( + title, + &format!("{title}"), + total_steps.max(1), + )?)) + } else { + let _ = app_name; + Ok(None) + } +} + +fn advance_gui_progress( + gui_progress: &mut Option, + current_step: usize, + message: &str, +) -> Result<(), AppError> { + if let Some(progress) = gui_progress.as_mut() { + progress.advance(current_step, message)?; + } + Ok(()) +} + +fn finish_gui_progress( + gui_progress: &mut Option, + message: &str, +) -> Result<(), AppError> { + if let Some(progress) = gui_progress.as_mut() { + progress.finish(message)?; + } + Ok(()) +} + +fn fail_gui_progress( + gui_progress: &mut Option, + message: &str, +) -> Result<(), AppError> { + if let Some(progress) = gui_progress.as_mut() { + progress.finish(message)?; + } + Ok(()) +} + +fn append_gui_shell_output( + gui_progress: &mut Option, + bytes: &[u8], +) -> Result<(), AppError> { + if bytes.is_empty() { + return Ok(()); + } + if let Some(progress) = gui_progress.as_mut() { + let text = String::from_utf8_lossy(bytes); + for line in text.lines().filter(|line| !line.trim().is_empty()) { + append_progress_log(&progress.log_path, line)?; + } + } + Ok(()) +} + +fn total_install_steps(manifest: &InstallManifest) -> usize { + manifest.directories.len() + + manifest.files.len() + + manifest.registry.len() + + manifest.shortcuts.len() + + manifest.scripts.len() + + 2 +} + +fn total_uninstall_steps(journal: &Journal) -> usize { + journal.actions.len() + journal.purge.registry_branches.len() + journal.purge.paths.len() + 2 +} + +fn schedule_helper_self_cleanup(logger: &Logger) -> Result { + let self_exe = std::env::current_exe()?; + logger.info("schedule_helper_self_cleanup", json!({"path":self_exe})); + let delete_command = format!( + "Start-Sleep -Seconds 2; Remove-Item -LiteralPath '{}' -Force -ErrorAction SilentlyContinue", + powershell_single_quote(&self_exe.to_string_lossy()) + ); + let mut command = Command::new("powershell.exe"); + command.creation_flags(CREATE_NO_WINDOW); + command.arg("-NoProfile"); + command.arg("-WindowStyle"); + command.arg("Hidden"); + command.arg("-Command"); + command.arg(OsString::from(delete_command)); + if command.spawn().is_ok() { + return Ok(false); + } + win::remove_file_with_fallback(&self_exe, logger)?; + Ok(true) +} + +fn write_progress_state( + state_path: &Path, + title: &str, + message: &str, + current_step: usize, + total_steps: usize, + complete: bool, +) -> Result<(), AppError> { + let progress = if total_steps == 0 { + 0 + } else { + ((current_step.min(total_steps) * 100) / total_steps) as u64 + }; + fs::write( + state_path, + serde_json::to_vec(&json!({ + "title": title, + "message": message, + "progress": progress, + "complete": complete + }))?, + )?; + Ok(()) +} + +fn append_progress_log(log_path: &Path, line: &str) -> Result<(), AppError> { + let mut file = fs::OpenOptions::new().append(true).open(log_path)?; + writeln!(file, "{line}")?; + Ok(()) +} + +fn spawn_gui_progress_window( + state_path: &Path, + log_path: &Path, + title: &str, +) -> Result<(), AppError> { + let state_path_ps = powershell_single_quote(&state_path.to_string_lossy()); + let log_path_ps = powershell_single_quote(&log_path.to_string_lossy()); + let title_ps = powershell_single_quote(title); + let script = format!(r#" +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$statePath = '{state_path_ps}' +$logPath = '{log_path_ps}' +$windowTitle = '{title_ps}' +$form = New-Object Windows.Forms.Form +$form.Text = $windowTitle +$form.Size = New-Object Drawing.Size(720,420) +$form.StartPosition = 'CenterScreen' +$label = New-Object Windows.Forms.Label +$label.Location = New-Object Drawing.Point(12,12) +$label.Size = New-Object Drawing.Size(680,24) +$label.Text = $windowTitle +$bar = New-Object Windows.Forms.ProgressBar +$bar.Location = New-Object Drawing.Point(12,44) +$bar.Size = New-Object Drawing.Size(680,24) +$bar.Minimum = 0 +$bar.Maximum = 100 +$output = New-Object Windows.Forms.TextBox +$output.Location = New-Object Drawing.Point(12,80) +$output.Size = New-Object Drawing.Size(680,288) +$output.Multiline = $true +$output.ScrollBars = 'Vertical' +$output.ReadOnly = $true +$output.Font = New-Object Drawing.Font('Consolas',9) +$timer = New-Object Windows.Forms.Timer +$timer.Interval = 250 +$timer.Add_Tick(({{ + try {{ + if (Test-Path $statePath) {{ + $state = Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($state.title) {{ $form.Text = $state.title }} + $label.Text = $state.message + $bar.Value = [Math]::Max(0, [Math]::Min(100, [int]$state.progress)) + if ([bool]$state.complete) {{ + $timer.Stop() + $form.Close() + }} + }} + }} catch {{ + # Ignore transient reads while Rust updates the state file. + }} + try {{ + if (Test-Path $logPath) {{ + $text = Get-Content -LiteralPath $logPath -Raw -ErrorAction Stop + if ($output.Text -ne $text) {{ + $output.Text = $text + $output.SelectionStart = $output.Text.Length + $output.ScrollToCaret() + }} + }} + }} catch {{ + # Ignore transient reads while Rust updates the log file. + }} +}}).GetNewClosure()) +$form.Controls.Add($label) +$form.Controls.Add($bar) +$form.Controls.Add($output) +$timer.Start() +[void]$form.ShowDialog() +"#); + let script_path = state_path.with_extension("ps1"); + fs::write(&script_path, &script)?; + let mut command = Command::new("powershell.exe"); + command.creation_flags(CREATE_NO_WINDOW); + command.arg("-STA"); + command.arg("-NoProfile"); + command.arg("-ExecutionPolicy"); + command.arg("Bypass"); + command.arg("-WindowStyle"); + command.arg("Hidden"); + command.arg("-File"); + command.arg(&script_path); + command.spawn()?; + Ok(()) +} + +fn unique_ticks() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +fn powershell_single_quote(value: &str) -> String { + value.replace('\'', "''") +} + +fn spawn_reboot(logger: &Logger) -> Result<(), AppError> { + logger.info("spawn_reboot", json!({})); + let mut command = Command::new("shutdown.exe"); + command.creation_flags(CREATE_NO_WINDOW); + command.args(["/r", "/t", "0"]); + command.spawn()?; + Ok(()) +} + +fn prompt_reboot_tui() -> Result { + print!("Restart now? [y/N]: "); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(matches!( + input.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +fn sanitize_registry_component(value: &str) -> String { + let sanitized: String = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "covenant_setup".to_string() + } else { + sanitized + } +} + +fn manifest_requires_admin( + manifest: &InstallManifest, + resolver: &win::PathResolver, +) -> Result { + for directory in &manifest.directories { + if path_requires_admin(&resolver.resolve(&directory.path)) { + return Ok(true); + } + } + for file in &manifest.files { + if path_requires_admin(&resolver.resolve(&file.destination)) { + return Ok(true); + } + } + for shortcut in &manifest.shortcuts { + if path_requires_admin(&resolver.resolve(&shortcut.path)) { + return Ok(true); + } + } + for key in &manifest.registry { + let (root, _) = parse_registry_key(&key.key)?; + if matches!(root, RegistryRoot::Hklm) { + return Ok(true); + } + } + Ok(false) +} + +fn journal_requires_admin( + journal: &Journal, + resolver: &win::PathResolver, +) -> Result { + for action in &journal.actions { + match action { + JournalAction::CreateDirectory { path } + | JournalAction::CopyFile { + destination: path, .. + } + | JournalAction::CreateShortcut { path } => { + if path_requires_admin(path) { + return Ok(true); + } + } + JournalAction::WriteRegistry { root, .. } if matches!(root, RegistryRoot::Hklm) => { + return Ok(true); + } + _ => {} + } + } + for branch in &journal.purge.registry_branches { + let (root, _) = parse_registry_key(branch)?; + if matches!(root, RegistryRoot::Hklm) { + return Ok(true); + } + } + for path in &journal.purge.paths { + if path_requires_admin(&resolver.resolve(path)) { + return Ok(true); + } + } + Ok(false) +} + +fn execute_script( + script: &ScriptSpec, + manifest_dir: Option<&Path>, + working_directory: Option<&Path>, + gui_progress: &mut Option, +) -> Result<(), AppError> { + let command_path = absolutize(manifest_dir, &script.command); + let command = if command_path.exists() { + command_path + } else { + PathBuf::from(&script.command) + }; + let mut process = Command::new(command); + process.creation_flags(CREATE_NO_WINDOW); + process.args(&script.args); + if let Some(dir) = working_directory { + process.current_dir(dir); + } + let output = process.output()?; + append_gui_shell_output(gui_progress, &output.stdout)?; + append_gui_shell_output(gui_progress, &output.stderr)?; + let status = output.status; + if !status.success() { + return Err(AppError::Message(format!( + "Script failed: {} ({status})", + script.command + ))); + } + Ok(()) +} + +fn purge_path(path: &Path, logger: &Logger) -> Result<(), AppError> { + if !path.exists() { + return Ok(()); + } + if path.is_file() { + return win::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)?; + } else { + win::remove_file_with_fallback(&child, logger)?; + } + } + win::remove_directory_if_exists(path, logger) +} + +fn parse_registry_key(input: &str) -> Result<(RegistryRoot, String), AppError> { + if let Some(rest) = input.strip_prefix("HKCU\\") { + return Ok((RegistryRoot::Hkcu, rest.to_string())); + } + if let Some(rest) = input.strip_prefix("HKLM\\") { + return Ok((RegistryRoot::Hklm, rest.to_string())); + } + Err(AppError::Message(format!( + "Unsupported registry root: {input}" + ))) +} + +fn is_uninstall_registry_key(subkey: &str) -> bool { + subkey.starts_with("Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\") +} + +fn same_path(left: &Path, right: &Path) -> bool { + 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() +} + +fn path_requires_admin(path: &Path) -> bool { + let path = path.to_string_lossy().to_ascii_lowercase(); + path.starts_with("c:\\program files") || path.starts_with("c:\\windows") +} + +fn absolutize(base: Option<&Path>, value: &str) -> PathBuf { + let candidate = PathBuf::from(value); + if candidate.is_absolute() { + candidate + } else { + base.unwrap_or_else(|| Path::new(".")).join(candidate) + } +} diff --git a/src/win.rs b/src/win.rs new file mode 100644 index 0000000..b6cf52a --- /dev/null +++ b/src/win.rs @@ -0,0 +1,672 @@ +use crate::{AppError, Logger, RegistryRoot}; +use serde_json::json; +use std::ffi::{OsStr, c_void}; +use std::fs; +use std::iter; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use windows::Win32::Foundation::{ + CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_MORE_DATA, ERROR_SUCCESS, HANDLE, HWND, WIN32_ERROR, +}; +use windows::Win32::Security::{GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation}; +use windows::Win32::Storage::FileSystem::{ + CopyFile2, CreateDirectoryW, DeleteFileW, MOVE_FILE_FLAGS, MOVEFILE_DELAY_UNTIL_REBOOT, + MoveFileExW, RemoveDirectoryW, +}; +use windows::Win32::System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, + CoTaskMemFree, CoUninitialize, IPersistFile, +}; +use windows::Win32::System::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, + RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegSetValueExW, +}; +use windows::Win32::System::RestartManager::{ + RM_PROCESS_INFO, RmEndSession, RmGetList, RmRegisterResources, RmStartSession, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, OpenProcessToken}; +use windows::Win32::UI::Controls::{ + TASKDIALOG_COMMON_BUTTON_FLAGS, TDCBF_CANCEL_BUTTON, TDCBF_NO_BUTTON, TDCBF_OK_BUTTON, + TDCBF_YES_BUTTON, TaskDialog, +}; +use windows::Win32::UI::Shell::{ + FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64, IShellLinkW, + KNOWN_FOLDER_FLAG, SHGetKnownFolderPath, ShellExecuteW, ShellLink, +}; +use windows::Win32::UI::WindowsAndMessaging::{IDOK, IDYES, SW_SHOW}; +use windows::core::{Interface, PCWSTR, PWSTR, w}; + +pub struct PathResolver { + pub program_files_x64: PathBuf, + pub local_app_data: PathBuf, + pub desktop: PathBuf, +} + +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 { + Ok(Self { + program_files_x64: known_folder(&FOLDERID_ProgramFilesX64, logger)?, + local_app_data: known_folder(&FOLDERID_LocalAppData, logger)?, + desktop: known_folder(&FOLDERID_Desktop, logger)?, + }) + } + + pub fn resolve(&self, input: &str) -> PathBuf { + PathBuf::from( + input + .replace( + "{ProgramFilesX64}", + &self.program_files_x64.to_string_lossy(), + ) + .replace("{LocalAppData}", &self.local_app_data.to_string_lossy()) + .replace("{Desktop}", &self.desktop.to_string_lossy()), + ) + } +} + +pub fn is_elevated(logger: &Logger) -> Result { + let mut token = HANDLE::default(); + logger.unsafe_enter("OpenProcessToken", json!({})); + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)? }; + logger.unsafe_exit("OpenProcessToken", json!({"opened": !token.is_invalid()})); + + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned = 0u32; + logger.unsafe_enter("GetTokenInformation", json!({"class":"TokenElevation"})); + unsafe { + GetTokenInformation( + token, + TokenElevation, + Some((&mut elevation as *mut TOKEN_ELEVATION).cast::()), + std::mem::size_of::() as u32, + &mut returned, + )? + }; + logger.unsafe_exit("GetTokenInformation", json!({"returned": returned})); + close_handle(token, logger)?; + if returned < std::mem::size_of::() as u32 { + return Err(AppError::Message("Short TOKEN_ELEVATION payload".into())); + } + Ok(elevation.TokenIsElevated != 0) +} + +pub fn relaunch_as_admin(logger: &Logger) -> Result<(), AppError> { + let exe = std::env::current_exe()?; + let params = std::env::args().skip(1).collect::>().join(" "); + logger.unsafe_enter( + "ShellExecuteW", + json!({"verb":"runas","exe":exe,"params":params}), + ); + let result = unsafe { + ShellExecuteW( + Some(HWND::default()), + w!("runas"), + PCWSTR(Utf16Arg::from_path(&exe).as_ptr()), + PCWSTR(Utf16Arg::from_str(¶ms).as_ptr()), + PCWSTR::null(), + SW_SHOW, + ) + }; + let code = result.0 as isize; + logger.unsafe_exit("ShellExecuteW", json!({"hinstance": code})); + if code <= 32 { + return Err(AppError::Message(format!("ShellExecuteW failed: {code}"))); + } + Ok(()) +} + +pub fn message_box( + title: &str, + body: &str, + buttons: TASKDIALOG_COMMON_BUTTON_FLAGS, + icon: PCWSTR, + logger: &Logger, +) -> Result { + let mut button = 0i32; + let title_w = Utf16Arg::from_str(title); + let body_w = Utf16Arg::from_str(body); + logger.unsafe_enter("TaskDialog", json!({"title":title})); + unsafe { + TaskDialog( + Some(HWND::default()), + None, + PCWSTR(title_w.as_ptr()), + PCWSTR::null(), + PCWSTR(body_w.as_ptr()), + buttons, + icon, + Some(&mut button), + )? + }; + logger.unsafe_exit("TaskDialog", json!({"result": button})); + Ok(button) +} + +pub fn gui_confirm_install(app_name: &str, logger: &Logger) -> Result { + let result = message_box( + "covenant-setup", + &format!("Install {app_name} now?"), + TDCBF_OK_BUTTON | TDCBF_CANCEL_BUTTON, + td_information_icon(), + logger, + )?; + Ok(result == IDOK.0) +} + +pub fn gui_report_success(app_name: &str, logger: &Logger) -> Result<(), AppError> { + let _ = message_box( + "covenant-setup", + &format!("{app_name} installation completed successfully"), + TDCBF_OK_BUTTON, + td_information_icon(), + logger, + )?; + Ok(()) +} + +pub fn gui_report_error(message: &str, logger: &Logger) -> Result<(), AppError> { + let _ = message_box( + "covenant-setup", + message, + TDCBF_OK_BUTTON, + td_error_icon(), + logger, + )?; + Ok(()) +} + +pub fn gui_report_uninstall_success(app_name: &str, logger: &Logger) -> Result<(), AppError> { + let _ = message_box( + "covenant-setup", + &format!("{app_name} uninstalled successfully!"), + TDCBF_OK_BUTTON, + td_information_icon(), + logger, + )?; + Ok(()) +} + +pub fn gui_prompt_uninstall_reboot(app_name: &str, logger: &Logger) -> Result { + let result = message_box( + "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." + ), + TDCBF_YES_BUTTON | TDCBF_NO_BUTTON, + td_information_icon(), + logger, + )?; + Ok(result == IDYES.0) +} + +pub fn create_directory_recursive(path: &Path, logger: &Logger) -> Result<(), AppError> { + if path.as_os_str().is_empty() || path.exists() { + return Ok(()); + } + if let Some(parent) = path.parent() { + if parent != path { + create_directory_recursive(parent, logger)?; + } + } + logger.unsafe_enter("CreateDirectoryW", json!({"path": path})); + let result = unsafe { CreateDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr()), None) }; + logger.unsafe_exit("CreateDirectoryW", json!({"ok": result.is_ok()})); + if let Err(err) = result { + if !path.exists() { + return Err(err.into()); + } + } + Ok(()) +} + +pub fn copy_file(source: &Path, destination: &Path, logger: &Logger) -> Result<(), AppError> { + let source_w = Utf16Arg::from_path(source); + let dest_w = Utf16Arg::from_path(destination); + logger.unsafe_enter( + "CopyFile2", + json!({"source":source,"destination":destination}), + ); + let result = unsafe { CopyFile2(PCWSTR(source_w.as_ptr()), PCWSTR(dest_w.as_ptr()), None) }; + logger.unsafe_exit("CopyFile2", json!({"ok": result.is_ok()})); + result?; + Ok(()) +} + +pub fn remove_directory_if_exists(path: &Path, logger: &Logger) -> Result<(), AppError> { + if !path.exists() { + return Ok(()); + } + logger.unsafe_enter("RemoveDirectoryW", json!({"path": path})); + let result = unsafe { RemoveDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) }; + logger.unsafe_exit("RemoveDirectoryW", json!({"ok": result.is_ok()})); + if let Err(err) = result { + if path.is_dir() && fs::read_dir(path)?.next().is_some() { + logger.info( + "remove_directory_deferred", + json!({"path":path,"reason":"not_empty"}), + ); + return Ok(()); + } + if path.exists() { + return Err(err.into()); + } + } + Ok(()) +} + +pub fn remove_file_with_fallback(path: &Path, logger: &Logger) -> Result<(), AppError> { + if !path.exists() { + return Ok(()); + } + logger.unsafe_enter("DeleteFileW", json!({"path": path})); + let delete_result = unsafe { DeleteFileW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) }; + logger.unsafe_exit("DeleteFileW", json!({"ok": delete_result.is_ok()})); + if delete_result.is_ok() { + return Ok(()); + } + let pids = get_locking_processes(path, logger).unwrap_or_default(); + if !pids.is_empty() { + logger.info("locked_file", json!({"path":path,"processes":pids})); + } + logger.unsafe_enter("MoveFileExW", json!({"path": path})); + let move_result = unsafe { + MoveFileExW( + PCWSTR(Utf16Arg::from_path(path).as_ptr()), + PCWSTR::null(), + MOVE_FILE_FLAGS(MOVEFILE_DELAY_UNTIL_REBOOT.0), + ) + }; + logger.unsafe_exit("MoveFileExW", json!({"ok": move_result.is_ok()})); + move_result?; + Ok(()) +} + +pub fn set_registry_string( + root: RegistryRoot, + subkey: &str, + name: &str, + value: &str, + logger: &Logger, +) -> Result<(), AppError> { + let mut key = HKEY::default(); + logger.unsafe_enter("RegCreateKeyExW", json!({"root":root,"subkey":subkey})); + let create_result = unsafe { + RegCreateKeyExW( + root_hkey(root), + PCWSTR(Utf16Arg::from_str(subkey).as_ptr()), + Some(0), + PWSTR::null(), + REG_OPEN_CREATE_OPTIONS(REG_OPTION_NON_VOLATILE.0), + REG_SAM_FLAGS(KEY_SET_VALUE.0 | KEY_WOW64_64KEY.0), + None, + &mut key, + None, + ) + }; + logger.unsafe_exit("RegCreateKeyExW", json!({"status": create_result.0})); + win32_ok(create_result, "RegCreateKeyExW")?; + + let utf16 = Utf16Arg::from_str(value); + logger.unsafe_enter("RegSetValueExW", json!({"name":name})); + let set_result = unsafe { + RegSetValueExW( + key, + PCWSTR(Utf16Arg::from_str(name).as_ptr()), + Some(0), + REG_VALUE_TYPE(REG_SZ.0), + Some(utf16.as_bytes()), + ) + }; + logger.unsafe_exit("RegSetValueExW", json!({"status": set_result.0})); + let close_result = close_registry_key(key, logger); + win32_ok(set_result, "RegSetValueExW")?; + close_result?; + Ok(()) +} + +pub fn delete_registry_tree( + root: RegistryRoot, + subkey: &str, + logger: &Logger, +) -> Result<(), AppError> { + logger.unsafe_enter("RegDeleteTreeW", json!({"root":root,"subkey":subkey})); + let result = + unsafe { RegDeleteTreeW(root_hkey(root), PCWSTR(Utf16Arg::from_str(subkey).as_ptr())) }; + logger.unsafe_exit("RegDeleteTreeW", json!({"status": result.0})); + if result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND { + return Ok(()); + } + win32_ok(result, "RegDeleteTreeW") +} + +pub fn create_shortcut( + shortcut_path: &Path, + target: &Path, + arguments: Option<&str>, + working_directory: Option<&Path>, + description: Option<&str>, + logger: &Logger, +) -> Result<(), AppError> { + logger.unsafe_enter("CoInitializeEx", json!({})); + unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()? }; + logger.unsafe_exit("CoInitializeEx", json!({"ok":true})); + let result = (|| -> Result<(), AppError> { + logger.unsafe_enter("CoCreateInstance", json!({"class":"ShellLink"})); + let link: IShellLinkW = + unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)? }; + logger.unsafe_exit("CoCreateInstance", json!({"ok":true})); + + logger.unsafe_enter("IShellLinkW::SetPath", json!({"target": target})); + unsafe { link.SetPath(PCWSTR(Utf16Arg::from_path(target).as_ptr()))? }; + logger.unsafe_exit("IShellLinkW::SetPath", json!({"ok":true})); + + if let Some(arguments) = arguments { + logger.unsafe_enter("IShellLinkW::SetArguments", json!({"arguments":arguments})); + unsafe { link.SetArguments(PCWSTR(Utf16Arg::from_str(arguments).as_ptr()))? }; + logger.unsafe_exit("IShellLinkW::SetArguments", json!({"ok":true})); + } + if let Some(working_directory) = working_directory { + logger.unsafe_enter( + "IShellLinkW::SetWorkingDirectory", + json!({"working_directory":working_directory}), + ); + unsafe { + link.SetWorkingDirectory(PCWSTR(Utf16Arg::from_path(working_directory).as_ptr()))? + }; + logger.unsafe_exit("IShellLinkW::SetWorkingDirectory", json!({"ok":true})); + } + if let Some(description) = description { + logger.unsafe_enter( + "IShellLinkW::SetDescription", + json!({"description":description}), + ); + unsafe { link.SetDescription(PCWSTR(Utf16Arg::from_str(description).as_ptr()))? }; + logger.unsafe_exit("IShellLinkW::SetDescription", json!({"ok":true})); + } + + logger.unsafe_enter("Interface::cast", json!({})); + let persist: IPersistFile = link.cast()?; + logger.unsafe_exit("Interface::cast", json!({"ok":true})); + logger.unsafe_enter("IPersistFile::Save", json!({"path":shortcut_path})); + unsafe { persist.Save(PCWSTR(Utf16Arg::from_path(shortcut_path).as_ptr()), true)? }; + logger.unsafe_exit("IPersistFile::Save", json!({"ok":true})); + Ok(()) + })(); + logger.unsafe_enter("CoUninitialize", json!({})); + unsafe { CoUninitialize() }; + logger.unsafe_exit("CoUninitialize", json!({"ok":true})); + result +} + +fn get_locking_processes(path: &Path, logger: &Logger) -> Result, AppError> { + let mut session = 0u32; + let mut key = [0u16; 33]; + logger.unsafe_enter("RmStartSession", json!({})); + let start_result = unsafe { RmStartSession(&mut session, Some(0), PWSTR(key.as_mut_ptr())) }; + logger.unsafe_exit( + "RmStartSession", + json!({"status":start_result.0,"session":session}), + ); + win32_ok(start_result, "RmStartSession")?; + + let file = Utf16Arg::from_path(path); + let resources = [PCWSTR(file.as_ptr())]; + logger.unsafe_enter("RmRegisterResources", json!({"path":path})); + let register_result = unsafe { RmRegisterResources(session, Some(&resources), None, None) }; + logger.unsafe_exit("RmRegisterResources", json!({"status":register_result.0})); + if let Err(err) = win32_ok(register_result, "RmRegisterResources") { + let _ = end_restart_manager_session(session, logger); + return Err(err); + } + + let mut needed = 0u32; + let mut count = 0u32; + let mut reasons = 0u32; + logger.unsafe_enter("RmGetList", json!({"phase":"probe"})); + let probe = unsafe { RmGetList(session, &mut needed, &mut count, None, &mut reasons) }; + logger.unsafe_exit( + "RmGetList", + json!({"phase":"probe","status":probe.0,"needed":needed}), + ); + if probe != ERROR_SUCCESS && probe != ERROR_MORE_DATA { + let _ = end_restart_manager_session(session, logger); + return win32_ok(probe, "RmGetList").map(|_| Vec::new()); + } + if needed == 0 { + end_restart_manager_session(session, logger)?; + return Ok(Vec::new()); + } + + let mut processes = vec![RM_PROCESS_INFO::default(); needed as usize]; + count = needed; + logger.unsafe_enter( + "RmGetList", + json!({"phase":"fetch","capacity":processes.len()}), + ); + let fetch = unsafe { + RmGetList( + session, + &mut needed, + &mut count, + Some(processes.as_mut_ptr()), + &mut reasons, + ) + }; + logger.unsafe_exit( + "RmGetList", + json!({"phase":"fetch","status":fetch.0,"count":count}), + ); + end_restart_manager_session(session, logger)?; + win32_ok(fetch, "RmGetList")?; + if count as usize > processes.len() { + return Err(AppError::Message( + "Restart Manager count exceeded allocated buffer".into(), + )); + } + Ok(processes + .into_iter() + .take(count as usize) + .map(|p| p.Process.dwProcessId) + .collect()) +} + +fn end_restart_manager_session(session: u32, logger: &Logger) -> Result<(), AppError> { + logger.unsafe_enter("RmEndSession", json!({"session":session})); + let result = unsafe { RmEndSession(session) }; + logger.unsafe_exit("RmEndSession", json!({"status":result.0})); + win32_ok(result, "RmEndSession") +} + +fn known_folder(id: &windows::core::GUID, logger: &Logger) -> Result { + logger.unsafe_enter("SHGetKnownFolderPath", json!({"folder":format!("{id:?}")})); + let raw = unsafe { SHGetKnownFolderPath(id, KNOWN_FOLDER_FLAG(0), None)? }; + logger.unsafe_exit( + "SHGetKnownFolderPath", + json!({"ptr_non_null":!raw.is_null()}), + ); + if raw.is_null() { + return Err(AppError::Message( + "SHGetKnownFolderPath returned null".into(), + )); + } + let path = pwstr_to_path(raw, logger)?; + logger.unsafe_enter("CoTaskMemFree", json!({})); + unsafe { CoTaskMemFree(Some(raw.0.cast())) }; + logger.unsafe_exit("CoTaskMemFree", json!({"ok":true})); + Ok(path) +} + +fn pwstr_to_path(raw: PWSTR, logger: &Logger) -> Result { + logger.unsafe_enter("PWSTR decode", json!({})); + unsafe { + let mut len = 0usize; + while *raw.0.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(raw.0, len); + let path = String::from_utf16(slice) + .map_err(|_| AppError::Message("Invalid UTF-16 from Win32".into()))?; + logger.unsafe_exit("PWSTR decode", json!({"len":len})); + Ok(PathBuf::from(path)) + } +} + +fn 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) }; + logger.unsafe_exit("CloseHandle", json!({"ok":result.is_ok()})); + result?; + Ok(()) +} + +fn close_registry_key(key: HKEY, logger: &Logger) -> Result<(), AppError> { + logger.unsafe_enter("RegCloseKey", json!({})); + let result = unsafe { RegCloseKey(key) }; + logger.unsafe_exit("RegCloseKey", json!({"status":result.0})); + win32_ok(result, "RegCloseKey") +} + +fn root_hkey(root: RegistryRoot) -> HKEY { + match root { + RegistryRoot::Hkcu => HKEY_CURRENT_USER, + RegistryRoot::Hklm => HKEY_LOCAL_MACHINE, + } +} + +fn td_information_icon() -> PCWSTR { + PCWSTR(std::ptr::without_provenance(0xFFFD)) +} + +fn td_error_icon() -> PCWSTR { + PCWSTR(std::ptr::without_provenance(0xFFFE)) +} + +fn win32_ok(status: WIN32_ERROR, operation: &str) -> Result<(), AppError> { + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(AppError::Message(format!( + "{operation} failed with Win32 error {}", + status.0 + ))) + } +} + +struct Utf16Arg { + inner: Vec, +} + +impl Utf16Arg { + fn from_path(path: &Path) -> Self { + Self { + inner: path + .as_os_str() + .encode_wide() + .chain(iter::once(0)) + .collect(), + } + } + + fn from_str(value: &str) -> Self { + Self { + inner: OsStr::new(value) + .encode_wide() + .chain(iter::once(0)) + .collect(), + } + } + + fn as_ptr(&self) -> *const u16 { + self.inner.as_ptr() + } + + fn as_bytes(&self) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + self.inner.as_ptr().cast::(), + self.inner.len() * std::mem::size_of::(), + ) + } + } +} -- 2.47.3 From ea006827f536763863ddec5c809e66332b5eedcc Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 27 Apr 2026 18:58:52 -0500 Subject: [PATCH 02/13] wip --- .gitignore | 2 + README.md | 35 ++- Vagrantfile | 36 ++++ scripts/run-windows-vm-smoke.ps1 | 202 ++++++++++++++++++ .../windows-vm/Approve-InstallerDialogs.ps1 | 26 +++ .../Invoke-InteractiveInstaller.ps1 | 54 +++++ .../Start-InteractiveSelfInstall.ps1 | 147 +++++++++++++ src/main.rs | 16 +- vm/self-test/install.toml | 25 +++ 9 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 Vagrantfile create mode 100644 scripts/run-windows-vm-smoke.ps1 create mode 100644 scripts/windows-vm/Approve-InstallerDialogs.ps1 create mode 100644 scripts/windows-vm/Invoke-InteractiveInstaller.ps1 create mode 100644 scripts/windows-vm/Start-InteractiveSelfInstall.ps1 create mode 100644 vm/self-test/install.toml diff --git a/.gitignore b/.gitignore index e1ea826..ab1763f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ # Added by cargo /dist* /target +/.vagrant/ +/vm/self-test/payload/ diff --git a/README.md b/README.md index 6908c6f..f9896e5 100644 --- a/README.md +++ b/README.md @@ -141,4 +141,37 @@ cargo fmt cargo check ``` -Interactive GUI/TUI flows have been exercised during development, but there is not yet a formal automated integration harness for packaged installer behavior. +Interactive GUI/TUI flows now have a Windows VM smoke harness for packaged installer behavior, while broader automated coverage is still limited. + +## Windows VM Smoke Test + +A Windows Hyper-V Vagrant VM now lives in [`Vagrantfile`](C:\Users\jasonross\workspace\covenant-setup\Vagrantfile), and the host harness in [`scripts/run-windows-vm-smoke.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\run-windows-vm-smoke.ps1) packages `covenant-setup`, boots the VM, opens Hyper-V's console viewer, and runs the packaged installer inside the guest's interactive desktop session. + +The self-install manifest used for this path lives at [`vm/self-test/install.toml`](C:\Users\jasonross\workspace\covenant-setup\vm\self-test\install.toml). The guest verifies that install produced: + +- `%LOCALAPPDATA%\CovenantSetupSelfTest\bin\covenant-setup.exe` +- `%LOCALAPPDATA%\CovenantSetupSelfTest\journal.json` +- `%LOCALAPPDATA%\CovenantSetupSelfTest\covenant-setup-uninstall.exe` +- `HKCU\Software\CovenantSetupSelfTest\InstallRoot` +- `Desktop\Covenant Setup Self Test.lnk` + +Run the smoke test from the repo root: + +```powershell +$env:COVENANT_WINDOWS_BOX = "gusztavvargadr/windows-11" +$env:COVENANT_HYPERV_SWITCH = "Default Switch" +.\scripts\run-windows-vm-smoke.ps1 +``` + +Notes: + +- The Vagrant provider is `hyperv`, and the box you choose must support that provider. +- The harness opens `vmconnect.exe` after `vagrant up` so the guest desktop stays visible during the install. +- The default Vagrant synced folder is disabled to avoid SMB credential prompts; the harness uploads the installer and guest scripts over WinRM instead. +- The guest install is launched through an interactive scheduled task because WinRM sessions are not desktop-visible. +- The packaged installer now has a hidden automation mode that suppresses blocking GUI message boxes while leaving the progress window visible for the VM smoke test. +- The Windows box should auto-log the `vagrant` user into the desktop session for the visual install path to appear. +- 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. +- Use `-HaltAfter` or `-DestroyAfter` if you want the harness to stop the VM after the test run. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..ff4729a --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,36 @@ +WINDOWS_BOX = ENV.fetch("COVENANT_WINDOWS_BOX", "gusztavvargadr/windows-11") +WINDOWS_BOX_VERSION = ENV["COVENANT_WINDOWS_BOX_VERSION"] +VM_NAME = ENV.fetch("COVENANT_VM_NAME", "covenant-setup-windows") +VM_MEMORY = ENV.fetch("COVENANT_VM_MEMORY", "6144") +VM_CPUS = ENV.fetch("COVENANT_VM_CPUS", "4") +WINRM_USERNAME = ENV.fetch("COVENANT_WINRM_USERNAME", "vagrant") +WINRM_PASSWORD = ENV.fetch("COVENANT_WINRM_PASSWORD", "vagrant") +HYPERV_SWITCH = ENV.fetch("COVENANT_HYPERV_SWITCH", "Default Switch") + +Vagrant.configure("2") do |config| + config.vm.box = WINDOWS_BOX + if WINDOWS_BOX_VERSION && !WINDOWS_BOX_VERSION.empty? + config.vm.box_version = WINDOWS_BOX_VERSION + end + + config.vm.hostname = VM_NAME + config.vm.guest = :windows + config.vm.communicator = "winrm" + config.vm.boot_timeout = ENV.fetch("COVENANT_BOOT_TIMEOUT", "1800").to_i + config.vm.graceful_halt_timeout = 180 + config.vm.box_check_update = false + config.vm.network "public_network", bridge: HYPERV_SWITCH + config.vm.synced_folder ".", "/vagrant", disabled: true + + config.winrm.username = WINRM_USERNAME + config.winrm.password = WINRM_PASSWORD + config.winrm.retry_limit = 60 + config.winrm.retry_delay = 10 + config.winrm.timeout = 1800 + + config.vm.provider "hyperv" do |h| + h.vmname = VM_NAME + h.memory = VM_MEMORY.to_i + h.cpus = VM_CPUS.to_i + end +end diff --git a/scripts/run-windows-vm-smoke.ps1 b/scripts/run-windows-vm-smoke.ps1 new file mode 100644 index 0000000..f0fcd1e --- /dev/null +++ b/scripts/run-windows-vm-smoke.ps1 @@ -0,0 +1,202 @@ +[CmdletBinding()] +param( + [string]$Provider = "hyperv", + [string]$ManifestPath = "vm\self-test\install.toml", + [string]$OutputRoot = "dist\vagrant-self-test", + [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" + +function Assert-Command { + param([Parameter(Mandatory)][string]$Name) + + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command not found on PATH: $Name" + } +} + +function Resolve-RepoPath { + param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$Path + ) + + if ([System.IO.Path]::IsPathRooted($Path)) { + return $Path + } + + return Join-Path $RepoRoot $Path +} + +function Convert-ToSingleQuotedPowerShellLiteral { + param([Parameter(Mandatory)][string]$Value) + + return "'" + $Value.Replace("'", "''") + "'" +} + +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 Open-HyperVViewer { + param([Parameter(Mandatory)][string]$VmName) + + $vmConnect = Join-Path $env:SystemRoot "System32\vmconnect.exe" + if (-not (Test-Path -LiteralPath $vmConnect)) { + Write-Warning "Hyper-V viewer not found at $vmConnect" + return + } + + Write-Host "==> $vmConnect localhost $VmName" + Start-Process -FilePath $vmConnect -ArgumentList @("localhost", $VmName) | Out-Null +} + +function Invoke-Process { + param( + [Parameter(Mandatory)][string]$FilePath, + [string[]]$Arguments = @() + ) + + Write-Host "==> $FilePath $($Arguments -join ' ')" + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Command failed with exit code $($process.ExitCode): $FilePath $($Arguments -join ' ')" + } +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$releaseExe = Join-Path $repoRoot "target\release\covenant-setup.exe" +$payloadRoot = Join-Path $repoRoot "vm\self-test\payload" +$stagedPayload = Join-Path $payloadRoot "covenant-setup.exe" +$manifestPathAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $ManifestPath +$outputRootAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $OutputRoot +$installerPath = Join-Path $outputRootAbs "covenant-setup-installer.exe" +$resultPath = Join-Path $outputRootAbs "guest-result.json" +$guestRoot = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke" +$guestInstallerPath = Join-Path $guestRoot "covenant-setup-installer.exe" +$guestResultPath = Join-Path $guestRoot "guest-result.json" +$guestScriptRoot = Join-Path $guestRoot "scripts" +$guestCommand = "& '$guestScriptRoot\Start-InteractiveSelfInstall.ps1' -InstallerPath '$guestInstallerPath' -ResultPath '$guestResultPath' -ScriptRoot '$guestScriptRoot'" + +Assert-Command -Name "cargo" +Assert-Command -Name "vagrant" + +New-Item -ItemType Directory -Force -Path $payloadRoot | Out-Null +New-Item -ItemType Directory -Force -Path $outputRootAbs | Out-Null +Remove-Item -LiteralPath $resultPath -Force -ErrorAction SilentlyContinue + +try { + if (-not $SkipBuild) { + Invoke-Tool -FilePath "cargo" -Arguments @("build", "--release") + } + + if (-not (Test-Path -LiteralPath $releaseExe)) { + throw "Release binary not found at $releaseExe" + } + + if (-not (Test-Path -LiteralPath $manifestPathAbs)) { + throw "Self-test manifest not found at $manifestPathAbs" + } + + Copy-Item -LiteralPath $releaseExe -Destination $stagedPayload -Force + Invoke-Process -FilePath $releaseExe -Arguments @("package", $manifestPathAbs, "--output", $outputRootAbs) + + if (-not (Test-Path -LiteralPath $installerPath)) { + throw "Packaged installer not found at $installerPath" + } + + if (-not $SkipVmBoot) { + Invoke-Vagrant -Arguments @("up", "--provider", $Provider) + + $guestUsernameLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestUsername + $guestPasswordLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestPassword + $enableAutoLogonCommand = @( + '$winlogon = ''HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon''' + "New-ItemProperty -Path `$winlogon -Name 'AutoAdminLogon' -PropertyType String -Value '1' -Force | Out-Null" + "New-ItemProperty -Path `$winlogon -Name 'ForceAutoLogon' -PropertyType String -Value '1' -Force | Out-Null" + "New-ItemProperty -Path `$winlogon -Name 'DefaultUserName' -PropertyType String -Value $guestUsernameLiteral -Force | Out-Null" + "New-ItemProperty -Path `$winlogon -Name 'DefaultPassword' -PropertyType String -Value $guestPasswordLiteral -Force | Out-Null" + "New-ItemProperty -Path `$winlogon -Name 'DefaultDomainName' -PropertyType String -Value `$env:COMPUTERNAME -Force | Out-Null" + ) -join "; " + Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $enableAutoLogonCommand) + Invoke-Vagrant -Arguments @("reload") + } + + 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) + + Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", "New-Item -ItemType Directory -Force -Path '$guestRoot' | Out-Null; New-Item -ItemType Directory -Force -Path '$guestScriptRoot' | Out-Null") + Invoke-Vagrant -Arguments @("upload", $installerPath, $guestInstallerPath) + Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Approve-InstallerDialogs.ps1"), (Join-Path $guestScriptRoot "Approve-InstallerDialogs.ps1")) + Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Invoke-InteractiveInstaller.ps1"), (Join-Path $guestScriptRoot "Invoke-InteractiveInstaller.ps1")) + Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Start-InteractiveSelfInstall.ps1"), (Join-Path $guestScriptRoot "Start-InteractiveSelfInstall.ps1")) + + Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $guestCommand) + + $resultJson = Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", "Get-Content -LiteralPath '$guestResultPath' -Raw") + Set-Content -LiteralPath $resultPath -Value $resultJson -Encoding UTF8 + + if (-not (Test-Path -LiteralPath $resultPath)) { + throw "Guest smoke-test result file was not written: $resultPath" + } + + $result = $resultJson | ConvertFrom-Json + if (-not $result.success) { + throw "Guest reported a failed smoke test: $($result.error)" + } + + Write-Host "" + Write-Host "Smoke test passed." + Write-Host "Installer: $installerPath" + Write-Host "Result JSON: $resultPath" + Write-Host "InstallRoot: $($result.installRoot)" +} +finally { + if ($DestroyAfter) { + try { + Invoke-Vagrant -Arguments @("destroy", "-f") + } + catch { + Write-Warning "Failed to destroy VM after smoke test: $($_.Exception.Message)" + } + } + elseif ($HaltAfter) { + try { + Invoke-Vagrant -Arguments @("halt") + } + catch { + Write-Warning "Failed to halt VM after smoke test: $($_.Exception.Message)" + } + } +} diff --git a/scripts/windows-vm/Approve-InstallerDialogs.ps1 b/scripts/windows-vm/Approve-InstallerDialogs.ps1 new file mode 100644 index 0000000..b7199ce --- /dev/null +++ b/scripts/windows-vm/Approve-InstallerDialogs.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][int]$InstallerProcessId, + [string]$WindowTitle = "covenant-setup", + [int]$PollMilliseconds = 500 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +Add-Type -AssemblyName Microsoft.VisualBasic +Add-Type -AssemblyName System.Windows.Forms + +while ($true) { + $process = Get-Process -Id $InstallerProcessId -ErrorAction SilentlyContinue + if (-not $process) { + break + } + + if ([Microsoft.VisualBasic.Interaction]::AppActivate($WindowTitle)) { + Start-Sleep -Milliseconds 200 + [System.Windows.Forms.SendKeys]::SendWait("{ENTER}") + } + + Start-Sleep -Milliseconds $PollMilliseconds +} diff --git a/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 new file mode 100644 index 0000000..a111538 --- /dev/null +++ b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$InstallerPath, + [Parameter(Mandatory)][string]$ResultPath, + [int]$TimeoutSeconds = 600 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$installer = $null +$startedAt = Get-Date + +try { + if (-not (Test-Path -LiteralPath $InstallerPath)) { + throw "Installer not found: $InstallerPath" + } + + $resultDir = Split-Path -Parent $ResultPath + if ($resultDir) { + New-Item -ItemType Directory -Force -Path $resultDir | Out-Null + } + Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue + + $installer = Start-Process -FilePath $InstallerPath -ArgumentList @("--headed", "--automation") -PassThru + + if (-not $installer.WaitForExit($TimeoutSeconds * 1000)) { + Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue + throw "Installer timed out after $TimeoutSeconds seconds." + } + + $result = [ordered]@{ + success = ($installer.ExitCode -eq 0) + exitCode = [int]$installer.ExitCode + installerPath = $InstallerPath + startedAt = $startedAt.ToString("o") + finishedAt = (Get-Date).ToString("o") + } + $result | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + + if ($installer.ExitCode -ne 0) { + exit $installer.ExitCode + } +} +catch { + $failure = [ordered]@{ + success = $false + error = $_.Exception.Message + startedAt = $startedAt.ToString("o") + finishedAt = (Get-Date).ToString("o") + } + $failure | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + exit 1 +} diff --git a/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 b/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 new file mode 100644 index 0000000..9c4933e --- /dev/null +++ b/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 @@ -0,0 +1,147 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$InstallerPath, + [Parameter(Mandatory)][string]$ResultPath, + [string]$ScriptRoot = $PSScriptRoot, + [int]$TimeoutSeconds = 600, + [string]$TaskNamePrefix = "CovenantSetupSelfInstall" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Quote-TaskArgument { + param([Parameter(Mandatory)][string]$Value) + + return '"' + $Value.Replace('"', '""') + '"' +} + +$installRoot = Join-Path $env:LOCALAPPDATA "CovenantSetupSelfTest" +$shortcutPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "Covenant Setup Self Test.lnk" +$registryPath = "HKCU:\Software\CovenantSetupSelfTest" +$journalPath = Join-Path $installRoot "journal.json" +$installedExe = Join-Path $installRoot "bin\covenant-setup.exe" +$uninstallExe = Join-Path $installRoot "covenant-setup-uninstall.exe" +$taskName = "{0}-{1}" -f $TaskNamePrefix, ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) +$interactiveScript = Join-Path $ScriptRoot "Invoke-InteractiveInstaller.ps1" + +try { + if (-not (Test-Path -LiteralPath $InstallerPath)) { + throw "Installer not found in guest: $InstallerPath" + } + + if (-not (Test-Path -LiteralPath $interactiveScript)) { + throw "Interactive installer script not found in guest: $interactiveScript" + } + + $resultDir = Split-Path -Parent $ResultPath + if ($resultDir) { + New-Item -ItemType Directory -Force -Path $resultDir | Out-Null + } + Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $shortcutPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue + + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) + $actionArguments = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", (Quote-TaskArgument -Value $interactiveScript), + "-InstallerPath", (Quote-TaskArgument -Value $InstallerPath), + "-ResultPath", (Quote-TaskArgument -Value $ResultPath), + "-TimeoutSeconds", $TimeoutSeconds + ) -join " " + $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArguments + $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + + Register-ScheduledTask ` + -TaskName $taskName ` + -Action $action ` + -Trigger $trigger ` + -Settings $settings ` + -Principal $principal ` + -Force | Out-Null + + Start-ScheduledTask -TaskName $taskName + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds + 120) + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $ResultPath) { + break + } + Start-Sleep -Seconds 2 + } + + if (-not (Test-Path -LiteralPath $ResultPath)) { + $taskInfo = Get-ScheduledTaskInfo -TaskName $taskName + throw "Timed out waiting for interactive installer task. LastTaskResult=$($taskInfo.LastTaskResult)" + } + + $runResult = Get-Content -LiteralPath $ResultPath -Raw | ConvertFrom-Json + if (-not $runResult.success) { + throw "Interactive installer task failed: $($runResult.error)" + } + + if (-not (Test-Path -LiteralPath $installedExe)) { + throw "Installed executable missing: $installedExe" + } + if (-not (Test-Path -LiteralPath $journalPath)) { + throw "Journal missing: $journalPath" + } + if (-not (Test-Path -LiteralPath $uninstallExe)) { + throw "Installed uninstaller missing: $uninstallExe" + } + if (-not (Test-Path -LiteralPath $shortcutPath)) { + throw "Desktop shortcut missing: $shortcutPath" + } + if (-not (Test-Path -LiteralPath $registryPath)) { + throw "Registry key missing: $registryPath" + } + + $installRootValue = Get-ItemPropertyValue -Path $registryPath -Name "InstallRoot" + if ($installRootValue -ne $installRoot) { + throw "Unexpected registry InstallRoot value: $installRootValue" + } + + $journal = Get-Content -LiteralPath $journalPath -Raw | ConvertFrom-Json + $journalActionTypes = @($journal.actions | ForEach-Object { $_.type }) + if ($journal.app_name -ne "Covenant Setup Self Test") { + throw "Unexpected journal app name: $($journal.app_name)" + } + if ($journalActionTypes -notcontains "copy_file") { + throw "Journal did not record the packaged payload copy." + } + + $verification = [ordered]@{ + success = $true + exitCode = [int]$runResult.exitCode + installerPath = $InstallerPath + installRoot = $installRoot + installedExe = $installedExe + journalPath = $journalPath + uninstallExe = $uninstallExe + shortcutPath = $shortcutPath + registryPath = $registryPath + journalActionTypes = $journalActionTypes + taskName = $taskName + startedAt = $runResult.startedAt + finishedAt = $runResult.finishedAt + } + $verification | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 +} +catch { + $failure = [ordered]@{ + success = $false + error = $_.Exception.Message + taskName = $taskName + installRoot = $installRoot + resultPath = $ResultPath + } + $failure | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + exit 1 +} +finally { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +} diff --git a/src/main.rs b/src/main.rs index 53c4409..75c2dfd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,8 @@ struct Cli { headless: bool, #[arg(long, global = true, action = ArgAction::SetTrue, conflicts_with = "headless")] headed: bool, + #[arg(long, global = true, hide = true, action = ArgAction::SetTrue)] + automation: bool, #[arg(long, global = true, action = ArgAction::SetTrue)] elevate: bool, #[command(subcommand)] @@ -328,6 +330,7 @@ enum RuntimeMode { struct UiPreferences { headless: bool, headed: bool, + automation: bool, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -698,18 +701,21 @@ fn run_bundled_installer( match mode { RuntimeMode::Bundled => { let ui_mode = select_ui(UiPhase::Install, preferences, logger)?; - if ui_mode == UiMode::Gui && !win::gui_confirm_install(&metadata.app_name, logger)? { + if ui_mode == UiMode::Gui + && !preferences.automation + && !win::gui_confirm_install(&metadata.app_name, logger)? + { return Ok(()); } match install(&manifest_path, Some(journal_path), true, ui_mode, logger) { Ok(()) => { - if ui_mode == UiMode::Gui { + if ui_mode == UiMode::Gui && !preferences.automation { win::gui_report_success(&metadata.app_name, logger)?; } Ok(()) } Err(err) => { - if ui_mode == UiMode::Gui { + if ui_mode == UiMode::Gui && !preferences.automation { win::gui_report_error(&err.to_string(), logger)?; } Err(err) @@ -1335,6 +1341,7 @@ 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(); @@ -1342,6 +1349,8 @@ fn parse_ui_preferences(args: &[OsString]) -> UiPreferences { preferences.headless = true; } else if value == "--headed" { preferences.headed = true; + } else if value == "--automation" { + preferences.automation = true; } } preferences @@ -1351,6 +1360,7 @@ fn ui_preferences_from_cli(cli: &Cli) -> UiPreferences { UiPreferences { headless: cli.headless, headed: cli.headed, + automation: cli.automation, } } diff --git a/vm/self-test/install.toml b/vm/self-test/install.toml new file mode 100644 index 0000000..0a489ae --- /dev/null +++ b/vm/self-test/install.toml @@ -0,0 +1,25 @@ +app_name = "Covenant Setup Self Test" + +[[directories]] +path = "{LocalAppData}\\CovenantSetupSelfTest" + +[[directories]] +path = "{LocalAppData}\\CovenantSetupSelfTest\\bin" + +[[files]] +source = "payload\\covenant-setup.exe" +destination = "{LocalAppData}\\CovenantSetupSelfTest\\bin\\covenant-setup.exe" + +[[registry]] +key = "HKCU\\Software\\CovenantSetupSelfTest" +name = "InstallRoot" +value = "{LocalAppData}\\CovenantSetupSelfTest" + +[[shortcuts]] +path = "{Desktop}\\Covenant Setup Self Test.lnk" +target = "{LocalAppData}\\CovenantSetupSelfTest\\bin\\covenant-setup.exe" +description = "Launch the Covenant Setup self-installed test payload" + +[purge] +registry_branches = ["HKCU\\Software\\CovenantSetupSelfTest"] +paths = ["{LocalAppData}\\CovenantSetupSelfTest"] -- 2.47.3 From 506dfbd66981564d5f6f087bc7c88b0849ca3dd4 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 27 Apr 2026 22:06:57 -0500 Subject: [PATCH 03/13] working install and uninstall flow in Vagrant --- .cargo/config.toml | 2 + .gitignore | 2 + Cargo.toml | 1 - README.md | 18 +- build.rs | 47 ++ scripts/run-windows-vm-smoke.ps1 | 108 ++++- scripts/windows-vm/Abort-SmokeDiagnostics.ps1 | 115 +++++ .../windows-vm/Approve-InstallerDialogs.ps1 | 26 - .../Invoke-InteractiveInstaller.ps1 | 150 +++++- .../Start-InteractiveSelfInstall.ps1 | 444 ++++++++++++++--- src/main.rs | 424 +++++++++-------- src/ui.rs | 344 ++++++++++++++ src/win.rs | 98 +--- ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj | 12 + ui/Covenant.Setup.Ui/Program.cs | 445 ++++++++++++++++++ ui/Covenant.Setup.Ui/app.manifest | 11 + 16 files changed, 1857 insertions(+), 390 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 scripts/windows-vm/Abort-SmokeDiagnostics.ps1 delete mode 100644 scripts/windows-vm/Approve-InstallerDialogs.ps1 create mode 100644 src/ui.rs create mode 100644 ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj create mode 100644 ui/Covenant.Setup.Ui/Program.cs create mode 100644 ui/Covenant.Setup.Ui/app.manifest diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ac2b23f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitignore b/.gitignore index ab1763f..c4f2643 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /target /.vagrant/ /vm/self-test/payload/ +**/bin/ +**/obj/ diff --git a/Cargo.toml b/Cargo.toml index 85cfe5f..8f2aeb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ windows = { version = "0.62.2", features = [ "Win32_System_RestartManager", "Win32_System_Registry", "Win32_System_Threading", - "Win32_UI_Controls", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } diff --git a/README.md b/README.md index f9896e5..7722bd1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Its current shape is: - Uninstalls in reverse order and purges declared registry/path namespaces - Registers the installed app in Windows Installed Apps / Add-Remove Programs - Creates an installed uninstaller executable in the app root +- Uses a C# WinForms presentation process for GUI progress and prompts +- Sends GUI state over named-pipe IPC from the Rust engine to the C# UI - Uses Win32 APIs through the `windows` crate with unsafe isolated in [`src/win.rs`](C:\Users\jasonross\workspace\covenant-setup\src\win.rs) - Logs every unsafe boundary transition @@ -44,6 +46,8 @@ 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. + ## Install and Uninstall Model Direct engine commands: @@ -87,13 +91,15 @@ Current automatic behavior: Current GUI behavior includes: -- native message-box prompts for confirmation and completion +- C# WinForms prompts for confirmation and completion - a progress window with: - progress bar - current operation text - scrolling operations log - reboot prompt when uninstall requires reboot to finish some cleanup +The Rust install engine owns all business logic and file/registry mutations. The C# process is presentation-only and receives JSON messages over a Windows named pipe. + ### TUI Current TUI behavior includes: @@ -124,11 +130,11 @@ The sample manifest lives at [`examples/install.toml`](C:\Users\jasonross\worksp ## Current Limitations -- The GUI layer is currently implemented through a PowerShell-hosted WinForms progress window rather than a native Rust GUI framework +- The GUI helper is embedded as a self-contained C# WinForms executable, which makes the installer binary substantially larger - The installer is not yet generating branded/custom themed installer screens - The manifest schema is still MVP-level and does not cover all production installer concerns - Script execution logs the script invocation; internal script mutations are not observed beyond declared purge coverage -- The packager currently embeds payload as JSON-appended data; this is functional but not yet optimized for large payloads or tamper-resistance +- The packager currently embeds payload in an appended raw bundle; this is functional but not yet compressed, signed, or tamper-resistant - No signing, MSI generation, compression, delta updates, or patching pipeline exists yet - No automated test suite has been added yet for end-to-end installer scenarios @@ -139,6 +145,7 @@ The codebase currently builds and formats successfully with: ```powershell cargo fmt cargo check +cargo build --release ``` Interactive GUI/TUI flows now have a Windows VM smoke harness for packaged installer behavior, while broader automated coverage is still limited. @@ -155,6 +162,8 @@ The self-install manifest used for this path lives at [`vm/self-test/install.tom - `HKCU\Software\CovenantSetupSelfTest\InstallRoot` - `Desktop\Covenant Setup Self Test.lnk` +It then immediately invokes the installed uninstaller with the generated journal and verifies that the install root, payload, journal, uninstaller, shortcut, application registry key, and Installed Apps registration are removed. + Run the smoke test from the repo root: ```powershell @@ -169,7 +178,8 @@ Notes: - The harness opens `vmconnect.exe` after `vagrant up` so the guest desktop stays visible during the install. - The default Vagrant synced folder is disabled to avoid SMB credential prompts; the harness uploads the installer and guest scripts over WinRM instead. - The guest install is launched through an interactive scheduled task because WinRM sessions are not desktop-visible. -- The packaged installer now has a hidden automation mode that suppresses blocking GUI message boxes while leaving the progress window visible for the VM smoke test. +- The packaged installer now has a hidden automation mode that suppresses blocking GUI prompts while leaving the C# progress window visible for the VM smoke test. +- The guest scripts write a trace bundle under `dist\vagrant-self-test\trace` after each smoke run. It includes `guest-events.jsonl`, scheduler/process/event snapshots, Rust heartbeat files named `installer-heartbeat-*.jsonl`, and C# UI pipe logs named `csharp-ui-pipe-*.jsonl`. - The Windows box should auto-log the `vagrant` user into the desktop session for the visual install path to appear. - 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`. diff --git a/build.rs b/build.rs index f0d8063..62cafd4 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,53 @@ use embed_manifest::embed_manifest; +use std::env; +use std::path::PathBuf; +use std::process::Command; fn main() { + publish_csharp_ui(); embed_manifest(embed_manifest::new_manifest("Comctl32")) .expect("unable to embed application manifest"); } + +fn publish_csharp_ui() { + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj"); + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/Program.cs"); + println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/app.manifest"); + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap()); + let project = manifest_dir.join("ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj"); + let publish_dir = out_dir.join("csharp-ui"); + let dotnet_home = out_dir.join("dotnet-home"); + let status = Command::new("dotnet") + .env("DOTNET_CLI_HOME", &dotnet_home) + .env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1") + .env("DOTNET_CLI_TELEMETRY_OPTOUT", "1") + .arg("publish") + .arg(&project) + .arg("--nologo") + .arg("--configuration") + .arg("Release") + .arg("--runtime") + .arg("win-x64") + .arg("--self-contained") + .arg("true") + .arg("-p:PublishSingleFile=true") + .arg("-p:IncludeNativeLibrariesForSelfExtract=true") + .arg("-p:PublishTrimmed=false") + .arg("-p:DebugType=none") + .arg("-p:DebugSymbols=false") + .arg("-o") + .arg(&publish_dir) + .status() + .expect("failed to launch dotnet publish for C# UI"); + if !status.success() { + panic!("dotnet publish failed for C# UI with {status}"); + } + + 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:rustc-env=COVENANT_SETUP_UI_EXE={}", ui_exe.display()); +} diff --git a/scripts/run-windows-vm-smoke.ps1 b/scripts/run-windows-vm-smoke.ps1 index f0fcd1e..9f6c7a5 100644 --- a/scripts/run-windows-vm-smoke.ps1 +++ b/scripts/run-windows-vm-smoke.ps1 @@ -65,6 +65,59 @@ function Invoke-Vagrant { Invoke-Tool -FilePath "vagrant" -Arguments $Arguments } +function Save-GuestTraceBundle { + param( + [Parameter(Mandatory)][string]$GuestTracePath, + [Parameter(Mandatory)][string]$GuestZipPath, + [Parameter(Mandatory)][string]$LocalTracePath + ) + + try { + New-Item -ItemType Directory -Force -Path $LocalTracePath | Out-Null + $guestTraceLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestTracePath + $guestZipLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestZipPath + $command = @( + "`$tracePath = $guestTraceLiteral" + "`$zipPath = $guestZipLiteral" + "if (-not (Test-Path -LiteralPath `$tracePath)) { Write-Output '__COVENANT_TRACE_EMPTY__'; exit 0 }" + "New-Item -ItemType File -Force -Path (Join-Path `$tracePath '.keep') | Out-Null" + "Remove-Item -LiteralPath `$zipPath -Force -ErrorAction SilentlyContinue" + "Compress-Archive -Path (Join-Path `$tracePath '*') -DestinationPath `$zipPath -Force" + "Write-Output '__COVENANT_TRACE_B64_START__'" + "[Convert]::ToBase64String([IO.File]::ReadAllBytes(`$zipPath))" + "Write-Output '__COVENANT_TRACE_B64_END__'" + ) -join "; " + + $output = Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $command) + $lines = @($output | ForEach-Object { $_.ToString().Trim() }) + if ($lines -contains "__COVENANT_TRACE_EMPTY__") { + Write-Warning "Guest trace path did not exist: $GuestTracePath" + return + } + + $start = [Array]::IndexOf($lines, "__COVENANT_TRACE_B64_START__") + $end = [Array]::IndexOf($lines, "__COVENANT_TRACE_B64_END__") + if ($start -lt 0 -or $end -le $start) { + Write-Warning "Guest trace bundle markers were not found in WinRM output." + return + } + + $base64 = (($lines[($start + 1)..($end - 1)]) -join "").Trim() + if (-not $base64) { + Write-Warning "Guest trace bundle was empty." + return + } + + $zipPath = Join-Path $LocalTracePath "guest-trace.zip" + [IO.File]::WriteAllBytes($zipPath, [Convert]::FromBase64String($base64)) + Expand-Archive -LiteralPath $zipPath -DestinationPath $LocalTracePath -Force + Write-Host "Trace bundle: $LocalTracePath" + } + catch { + Write-Warning "Failed to collect guest trace bundle: $($_.Exception.Message)" + } +} + function Open-HyperVViewer { param([Parameter(Mandatory)][string]$VmName) @@ -91,6 +144,38 @@ function Invoke-Process { } } +function Assert-PackagedInstallerBundle { + param([Parameter(Mandatory)][string]$InstallerPath) + + $magic = [Text.Encoding]::ASCII.GetBytes("COVENANT_SETUP_BUNDLE_V1") + $stream = [IO.File]::Open( + $InstallerPath, + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::ReadWrite) + try { + if ($stream.Length -lt $magic.Length) { + throw "Packaged installer is too small to contain the embedded bundle marker: $InstallerPath" + } + + $null = $stream.Seek(-1 * $magic.Length, [IO.SeekOrigin]::End) + $actual = [byte[]]::new($magic.Length) + $read = $stream.Read($actual, 0, $actual.Length) + if ($read -ne $magic.Length) { + throw "Could not read embedded bundle marker from packaged installer: $InstallerPath" + } + + for ($i = 0; $i -lt $magic.Length; $i++) { + if ($actual[$i] -ne $magic[$i]) { + throw "Packaged installer is missing the embedded bundle marker: $InstallerPath" + } + } + } + finally { + $stream.Dispose() + } +} + $repoRoot = Split-Path -Parent $PSScriptRoot $releaseExe = Join-Path $repoRoot "target\release\covenant-setup.exe" $payloadRoot = Join-Path $repoRoot "vm\self-test\payload" @@ -99,18 +184,23 @@ $manifestPathAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $ManifestPath $outputRootAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $OutputRoot $installerPath = Join-Path $outputRootAbs "covenant-setup-installer.exe" $resultPath = Join-Path $outputRootAbs "guest-result.json" +$traceRootAbs = Join-Path $outputRootAbs "trace" $guestRoot = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke" $guestInstallerPath = Join-Path $guestRoot "covenant-setup-installer.exe" $guestResultPath = Join-Path $guestRoot "guest-result.json" +$guestTraceRoot = Join-Path $guestRoot "trace" +$guestTraceZipPath = Join-Path $guestRoot "trace.zip" $guestScriptRoot = Join-Path $guestRoot "scripts" -$guestCommand = "& '$guestScriptRoot\Start-InteractiveSelfInstall.ps1' -InstallerPath '$guestInstallerPath' -ResultPath '$guestResultPath' -ScriptRoot '$guestScriptRoot'" +$guestCommand = "& '$guestScriptRoot\Start-InteractiveSelfInstall.ps1' -InstallerPath '$guestInstallerPath' -ResultPath '$guestResultPath' -ScriptRoot '$guestScriptRoot' -TracePath '$guestTraceRoot'" Assert-Command -Name "cargo" +Assert-Command -Name "dotnet" Assert-Command -Name "vagrant" New-Item -ItemType Directory -Force -Path $payloadRoot | Out-Null New-Item -ItemType Directory -Force -Path $outputRootAbs | Out-Null Remove-Item -LiteralPath $resultPath -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $traceRootAbs -Recurse -Force -ErrorAction SilentlyContinue try { if (-not $SkipBuild) { @@ -131,6 +221,7 @@ try { if (-not (Test-Path -LiteralPath $installerPath)) { throw "Packaged installer not found at $installerPath" } + Assert-PackagedInstallerBundle -InstallerPath $installerPath if (-not $SkipVmBoot) { Invoke-Vagrant -Arguments @("up", "--provider", $Provider) @@ -157,8 +248,15 @@ try { Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $waitForShellCommand) Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", "New-Item -ItemType Directory -Force -Path '$guestRoot' | Out-Null; New-Item -ItemType Directory -Force -Path '$guestScriptRoot' | Out-Null") + $clearGuestTraceCommand = @( + "try {" + "if (Test-Path -LiteralPath '$guestTraceRoot') { Remove-Item -LiteralPath '$guestTraceRoot' -Recurse -Force -ErrorAction SilentlyContinue }" + "if (Test-Path -LiteralPath '$guestTraceZipPath') { Remove-Item -LiteralPath '$guestTraceZipPath' -Force -ErrorAction SilentlyContinue }" + "} catch { Write-Warning `$_.Exception.Message }" + "exit 0" + ) -join "; " + Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $clearGuestTraceCommand) Invoke-Vagrant -Arguments @("upload", $installerPath, $guestInstallerPath) - Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Approve-InstallerDialogs.ps1"), (Join-Path $guestScriptRoot "Approve-InstallerDialogs.ps1")) Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Invoke-InteractiveInstaller.ps1"), (Join-Path $guestScriptRoot "Invoke-InteractiveInstaller.ps1")) Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Start-InteractiveSelfInstall.ps1"), (Join-Path $guestScriptRoot "Start-InteractiveSelfInstall.ps1")) @@ -180,9 +278,13 @@ try { Write-Host "Smoke test passed." Write-Host "Installer: $installerPath" Write-Host "Result JSON: $resultPath" - Write-Host "InstallRoot: $($result.installRoot)" + Write-Host "Trace: $traceRootAbs" + Write-Host "InstallRoot: $($result.installRoot) (installed, then removed)" + Write-Host "Uninstall: exit $($result.uninstallExitCode), verified=$($result.uninstallVerified)" } finally { + Save-GuestTraceBundle -GuestTracePath $guestTraceRoot -GuestZipPath $guestTraceZipPath -LocalTracePath $traceRootAbs + if ($DestroyAfter) { try { Invoke-Vagrant -Arguments @("destroy", "-f") diff --git a/scripts/windows-vm/Abort-SmokeDiagnostics.ps1 b/scripts/windows-vm/Abort-SmokeDiagnostics.ps1 new file mode 100644 index 0000000..8085d31 --- /dev/null +++ b/scripts/windows-vm/Abort-SmokeDiagnostics.ps1 @@ -0,0 +1,115 @@ +param( + [string]$TracePath = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace", + [string]$ZipPath = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace-abort.zip", + [string]$TaskNamePrefix = "CovenantSetupSelfInstall" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Continue" + +function Write-TraceEvent { + param( + [Parameter(Mandatory)][string]$Phase, + [object]$Detail = $null + ) + + try { + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + $event = [ordered]@{ + time = (Get-Date).ToUniversalTime().ToString("o") + pid = $PID + script = Split-Path -Leaf $PSCommandPath + phase = $Phase + detail = $Detail + } + $event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8 + } + catch { + Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)" + } +} + +function Write-DiagnosticFile { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Capture + ) + + $path = Join-Path $TracePath $Name + Write-TraceEvent -Phase "abort_diagnostic_file_start" -Detail @{ name = $Name } + try { + $value = & $Capture + $value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + Write-TraceEvent -Phase "abort_diagnostic_file_finish" -Detail @{ name = $Name } + } + catch { + [ordered]@{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8 + Write-TraceEvent -Phase "abort_diagnostic_file_error" -Detail @{ + name = $Name + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } + } +} + +New-Item -ItemType Directory -Force -Path $TracePath | Out-Null +Write-TraceEvent -Phase "abort_requested" + +Write-DiagnosticFile -Name "abort-processes.json" -Capture { + Get-CimInstance Win32_Process | + Where-Object { + $_.Name -match "covenant|Covenant|powershell|pwsh" -or + $_.CommandLine -match "CovenantSetup|Invoke-InteractiveInstaller|Start-InteractiveSelfInstall" + } | + Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate +} +Write-DiagnosticFile -Name "abort-windows.json" -Capture { + Get-Process | + Where-Object { $_.MainWindowHandle -ne 0 -or $_.ProcessName -match "covenant|Covenant|powershell|pwsh" } | + Select-Object Id, ProcessName, MainWindowTitle, MainWindowHandle, StartTime +} +Write-DiagnosticFile -Name "abort-scheduled-tasks.json" -Capture { + Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue | + Select-Object TaskName, State, TaskPath, Actions, Triggers +} +Write-DiagnosticFile -Name "abort-scheduled-task-info.json" -Capture { + Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue | + ForEach-Object { Get-ScheduledTaskInfo -TaskName $_.TaskName -ErrorAction SilentlyContinue } | + Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns +} +Write-DiagnosticFile -Name "abort-application-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message +} +Write-DiagnosticFile -Name "abort-system-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "System"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message +} + +Get-Process -Name "covenant-setup-installer", "covenant-setup", "covenant-setup-uninstall", "Covenant.Setup.Ui" -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + +Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue | + Stop-ScheduledTask -ErrorAction SilentlyContinue +Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue | + Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue + +$scriptProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.ProcessId -ne $PID -and + $_.CommandLine -match "Invoke-InteractiveInstaller|Start-InteractiveSelfInstall" + } +foreach ($process in $scriptProcesses) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue +} + +Write-TraceEvent -Phase "abort_cleanup_complete" + +Remove-Item -LiteralPath $ZipPath -Force -ErrorAction SilentlyContinue +Compress-Archive -Path (Join-Path $TracePath "*") -DestinationPath $ZipPath -Force +Write-Output "__COVENANT_TRACE_B64_START__" +[Convert]::ToBase64String([IO.File]::ReadAllBytes($ZipPath)) +Write-Output "__COVENANT_TRACE_B64_END__" diff --git a/scripts/windows-vm/Approve-InstallerDialogs.ps1 b/scripts/windows-vm/Approve-InstallerDialogs.ps1 deleted file mode 100644 index b7199ce..0000000 --- a/scripts/windows-vm/Approve-InstallerDialogs.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)][int]$InstallerProcessId, - [string]$WindowTitle = "covenant-setup", - [int]$PollMilliseconds = 500 -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -Add-Type -AssemblyName Microsoft.VisualBasic -Add-Type -AssemblyName System.Windows.Forms - -while ($true) { - $process = Get-Process -Id $InstallerProcessId -ErrorAction SilentlyContinue - if (-not $process) { - break - } - - if ([Microsoft.VisualBasic.Interaction]::AppActivate($WindowTitle)) { - Start-Sleep -Milliseconds 200 - [System.Windows.Forms.SendKeys]::SendWait("{ENTER}") - } - - Start-Sleep -Milliseconds $PollMilliseconds -} diff --git a/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 index a111538..bad0065 100644 --- a/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 +++ b/scripts/windows-vm/Invoke-InteractiveInstaller.ps1 @@ -2,16 +2,120 @@ param( [Parameter(Mandatory)][string]$InstallerPath, [Parameter(Mandatory)][string]$ResultPath, - [int]$TimeoutSeconds = 600 + [int]$TimeoutSeconds = 600, + [string]$TracePath = $(Join-Path (Split-Path -Parent $ResultPath) "trace"), + [string]$OperationName = "installer", + [string]$InstallerArgumentsBase64 = "", + [string[]]$InstallerArguments = @("--headed", "--automation") ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +function Write-TraceEvent { + param( + [Parameter(Mandatory)][string]$Phase, + [object]$Detail = $null + ) + + try { + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + $event = [ordered]@{ + time = (Get-Date).ToUniversalTime().ToString("o") + pid = $PID + script = Split-Path -Leaf $PSCommandPath + phase = $Phase + detail = $Detail + } + $event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8 + } + catch { + Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)" + } +} + +function Write-DiagnosticFile { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Capture + ) + + $path = Join-Path $TracePath $Name + try { + $value = & $Capture + $value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + } + catch { + [ordered]@{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8 + } +} + +function Export-InstallerDiagnostics { + param( + [Parameter(Mandatory)][string]$Reason, + [System.Diagnostics.Process]$InstallerProcess = $null + ) + + Write-TraceEvent -Phase "installer_diagnostics_start" -Detail @{ reason = $Reason; installerPid = $(if ($InstallerProcess) { $InstallerProcess.Id } else { $null }) } + Write-DiagnosticFile -Name "interactive-context.json" -Capture { + [ordered]@{ + reason = $Reason + computerName = $env:COMPUTERNAME + userName = $env:USERNAME + sessionName = $env:SESSIONNAME + installerPath = $InstallerPath + installerArgs = $InstallerArguments + operationName = $OperationName + resultPath = $ResultPath + tracePath = $TracePath + timeoutSeconds = $TimeoutSeconds + installerPid = $(if ($InstallerProcess) { $InstallerProcess.Id } else { $null }) + installerExited = $(if ($InstallerProcess) { $InstallerProcess.HasExited } else { $null }) + } + } + Write-DiagnosticFile -Name "interactive-processes.json" -Capture { + Get-CimInstance Win32_Process | + Where-Object { $_.Name -match 'covenant|Covenant|powershell|pwsh|dotnet' } | + Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate + } + Write-DiagnosticFile -Name "interactive-windows.json" -Capture { + Get-Process | + Where-Object { $_.MainWindowHandle -ne 0 -or $_.ProcessName -match 'covenant|Covenant|powershell|pwsh' } | + Select-Object Id, ProcessName, MainWindowTitle, MainWindowHandle, StartTime + } + Write-DiagnosticFile -Name "interactive-application-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message + } + Write-TraceEvent -Phase "installer_diagnostics_finish" -Detail @{ reason = $Reason } +} + $installer = $null $startedAt = Get-Date try { + if (-not [string]::IsNullOrWhiteSpace($InstallerArgumentsBase64)) { + $argumentsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($InstallerArgumentsBase64)) + $decodedArguments = ConvertFrom-Json -InputObject $argumentsJson + $InstallerArguments = @() + foreach ($argument in $decodedArguments) { + $InstallerArguments += [string]$argument + } + } + + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + Write-TraceEvent -Phase "interactive_installer_start" -Detail @{ + installerPath = $InstallerPath + installerArgs = $InstallerArguments + operationName = $OperationName + resultPath = $ResultPath + tracePath = $TracePath + timeoutSeconds = $TimeoutSeconds + } + if (-not (Test-Path -LiteralPath $InstallerPath)) { throw "Installer not found: $InstallerPath" } @@ -22,17 +126,47 @@ try { } Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue - $installer = Start-Process -FilePath $InstallerPath -ArgumentList @("--headed", "--automation") -PassThru + $env:COVENANT_SETUP_TRACE_DIR = $TracePath + Write-TraceEvent -Phase "trace_environment_set" -Detail @{ name = "COVENANT_SETUP_TRACE_DIR"; value = $TracePath } - if (-not $installer.WaitForExit($TimeoutSeconds * 1000)) { - Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue - throw "Installer timed out after $TimeoutSeconds seconds." + $installer = Start-Process -FilePath $InstallerPath -ArgumentList $InstallerArguments -PassThru + Write-TraceEvent -Phase "installer_process_started" -Detail @{ pid = $installer.Id; operationName = $OperationName } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastPoll = Get-Date "2000-01-01" + while (-not $installer.HasExited) { + if ((Get-Date) -ge $deadline) { + Export-InstallerDiagnostics -Reason "installer_timeout" -InstallerProcess $installer + Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue + throw "Installer timed out after $TimeoutSeconds seconds." + } + + if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) { + $lastPoll = Get-Date + $installer.Refresh() + Write-TraceEvent -Phase "installer_still_running" -Detail @{ + pid = $installer.Id + operationName = $OperationName + elapsedSeconds = [int]((Get-Date) - $startedAt).TotalSeconds + responding = $installer.Responding + mainWindow = $installer.MainWindowTitle + } + } + + Start-Sleep -Seconds 2 + $installer.Refresh() } + Write-TraceEvent -Phase "installer_process_exited" -Detail @{ pid = $installer.Id; exitCode = $installer.ExitCode; operationName = $OperationName } + Export-InstallerDiagnostics -Reason "installer_exit" -InstallerProcess $installer + $result = [ordered]@{ success = ($installer.ExitCode -eq 0) exitCode = [int]$installer.ExitCode installerPath = $InstallerPath + installerArgs = $InstallerArguments + operationName = $OperationName + tracePath = $TracePath startedAt = $startedAt.ToString("o") finishedAt = (Get-Date).ToString("o") } @@ -43,9 +177,15 @@ try { } } catch { + Write-TraceEvent -Phase "interactive_installer_error" -Detail @{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } + Export-InstallerDiagnostics -Reason "interactive_installer_error" -InstallerProcess $installer $failure = [ordered]@{ success = $false error = $_.Exception.Message + tracePath = $TracePath startedAt = $startedAt.ToString("o") finishedAt = (Get-Date).ToString("o") } diff --git a/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 b/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 index 9c4933e..3553945 100644 --- a/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 +++ b/scripts/windows-vm/Start-InteractiveSelfInstall.ps1 @@ -4,7 +4,8 @@ param( [Parameter(Mandatory)][string]$ResultPath, [string]$ScriptRoot = $PSScriptRoot, [int]$TimeoutSeconds = 600, - [string]$TaskNamePrefix = "CovenantSetupSelfInstall" + [string]$TaskNamePrefix = "CovenantSetupSelfInstall", + [string]$TracePath = $(Join-Path (Split-Path -Parent $ResultPath) "trace") ) Set-StrictMode -Version Latest @@ -16,16 +17,310 @@ function Quote-TaskArgument { return '"' + $Value.Replace('"', '""') + '"' } +function Write-TraceEvent { + param( + [Parameter(Mandatory)][string]$Phase, + [object]$Detail = $null + ) + + try { + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + $event = [ordered]@{ + time = (Get-Date).ToUniversalTime().ToString("o") + pid = $PID + script = Split-Path -Leaf $PSCommandPath + phase = $Phase + detail = $Detail + } + $event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8 + } + catch { + Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)" + } +} + +function Write-DiagnosticFile { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][scriptblock]$Capture + ) + + $path = Join-Path $TracePath $Name + Write-TraceEvent -Phase "diagnostic_file_start" -Detail @{ name = $Name } + try { + $value = & $Capture + $value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 + Write-TraceEvent -Phase "diagnostic_file_finish" -Detail @{ name = $Name } + } + catch { + [ordered]@{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8 + Write-TraceEvent -Phase "diagnostic_file_error" -Detail @{ + name = $Name + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } + } +} + +function Convert-DateTimeForTrace { + param([object]$Value) + + if ($null -eq $Value) { + return $null + } + + if ($Value -is [DateTime] -and $Value -eq [DateTime]::MinValue) { + return $null + } + + try { + return ([DateTime]$Value).ToString("o") + } + catch { + return [string]$Value + } +} + +function Export-SmokeDiagnostics { + param([Parameter(Mandatory)][string]$Reason) + + Write-TraceEvent -Phase "diagnostics_start" -Detail @{ reason = $Reason } + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + + Write-DiagnosticFile -Name "guest-context.json" -Capture { + [ordered]@{ + reason = $Reason + computerName = $env:COMPUTERNAME + userName = $env:USERNAME + installerPath = $InstallerPath + resultPath = $ResultPath + tracePath = $TracePath + taskName = $taskName + installRoot = $installRoot + } + } + Write-DiagnosticFile -Name "processes.json" -Capture { + Get-CimInstance Win32_Process | + Where-Object { $_.Name -match 'covenant|Covenant|powershell|pwsh|dotnet' } | + Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate + } + Write-DiagnosticFile -Name "scheduled-task.json" -Capture { + [ordered]@{ + task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue + } + } + Write-DiagnosticFile -Name "scheduled-task-events.json" -Capture { + Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 300 -ErrorAction SilentlyContinue | + Where-Object { $_.Message -like "*$taskName*" } | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message + } + Write-DiagnosticFile -Name "application-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message + } + Write-DiagnosticFile -Name "system-events.json" -Capture { + Get-WinEvent -FilterHashtable @{ LogName = "System"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | + Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message + } + Write-DiagnosticFile -Name "install-root-files.json" -Capture { + if (Test-Path -LiteralPath $installRoot) { + Get-ChildItem -LiteralPath $installRoot -Force -Recurse | + Select-Object FullName, Length, LastWriteTimeUtc, Attributes + } + else { + [ordered]@{ exists = $false; path = $installRoot } + } + } + Write-DiagnosticFile -Name "registry-state.json" -Capture { + if (Test-Path -LiteralPath $registryPath) { + Get-ItemProperty -LiteralPath $registryPath + } + else { + [ordered]@{ exists = $false; path = $registryPath } + } + } + Write-DiagnosticFile -Name "result-file.json" -Capture { + if (Test-Path -LiteralPath $ResultPath) { + Get-Content -LiteralPath $ResultPath -Raw + } + else { + [ordered]@{ exists = $false; path = $ResultPath } + } + } + Write-TraceEvent -Phase "diagnostics_finish" -Detail @{ reason = $Reason } +} + +function Invoke-InteractiveOperation { + param( + [Parameter(Mandatory)][string]$TaskName, + [Parameter(Mandatory)][string]$ExecutablePath, + [Parameter(Mandatory)][string]$RunResultPath, + [Parameter(Mandatory)][string]$OperationName, + [string[]]$Arguments = @() + ) + + $script:taskName = $TaskName + Remove-Item -LiteralPath $RunResultPath -Force -ErrorAction SilentlyContinue + + # The task is started manually below. Keep the trigger far enough out that it + # cannot fire a second copy while the smoke harness is collecting diagnostics. + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddDays(1) + $actionArgumentItems = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", (Quote-TaskArgument -Value $interactiveScript), + "-InstallerPath", (Quote-TaskArgument -Value $ExecutablePath), + "-ResultPath", (Quote-TaskArgument -Value $RunResultPath), + "-TimeoutSeconds", $TimeoutSeconds, + "-TracePath", (Quote-TaskArgument -Value $TracePath), + "-OperationName", (Quote-TaskArgument -Value $OperationName) + ) + if ($Arguments.Count -gt 0) { + $argumentsJson = ConvertTo-Json -InputObject $Arguments -Compress + $argumentsBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($argumentsJson)) + $actionArgumentItems += "-InstallerArgumentsBase64" + $actionArgumentItems += $argumentsBase64 + } + $actionArguments = $actionArgumentItems -join " " + $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArguments + $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable + + try { + Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Settings $settings ` + -Principal $principal ` + -Force | Out-Null + Write-TraceEvent -Phase "scheduled_task_registered" -Detail @{ + taskName = $TaskName + operationName = $OperationName + executablePath = $ExecutablePath + executableArgs = $Arguments + actionArguments = $actionArguments + runResultPath = $RunResultPath + } + + Start-ScheduledTask -TaskName $TaskName + Write-TraceEvent -Phase "scheduled_task_started" -Detail @{ taskName = $TaskName; operationName = $OperationName } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds + 120) + $lastPoll = Get-Date "2000-01-01" + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $RunResultPath) { + Write-TraceEvent -Phase "result_file_observed" -Detail @{ + taskName = $TaskName + operationName = $OperationName + runResultPath = $RunResultPath + } + break + } + + if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) { + $lastPoll = Get-Date + $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Write-TraceEvent -Phase "waiting_for_interactive_task" -Detail @{ + taskName = $TaskName + operationName = $OperationName + state = $(if ($task) { $task.State.ToString() } else { $null }) + lastRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.LastRunTime } else { $null }) + lastTaskResult = $(if ($taskInfo) { $taskInfo.LastTaskResult } else { $null }) + nextRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.NextRunTime } else { $null }) + } + } + + Start-Sleep -Seconds 2 + } + + if (-not (Test-Path -LiteralPath $RunResultPath)) { + Export-SmokeDiagnostics -Reason "${OperationName}_task_timeout" + $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName + throw "Timed out waiting for $OperationName interactive task. LastTaskResult=$($taskInfo.LastTaskResult)" + } + + $runResult = Get-Content -LiteralPath $RunResultPath -Raw | ConvertFrom-Json + Write-TraceEvent -Phase "interactive_task_result_read" -Detail @{ + taskName = $TaskName + operationName = $OperationName + result = $runResult + } + if (-not $runResult.success) { + Export-SmokeDiagnostics -Reason "${OperationName}_task_failed" + throw "Interactive $OperationName task failed: $($runResult.error)" + } + + return $runResult + } + finally { + Write-TraceEvent -Phase "scheduled_task_unregister" -Detail @{ taskName = $TaskName; operationName = $OperationName } + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + } +} + +function Wait-ForUninstallCleanup { + param([int]$TimeoutSeconds = 60) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastPoll = Get-Date "2000-01-01" + while ((Get-Date) -lt $deadline) { + $remaining = [ordered]@{ + installRoot = Test-Path -LiteralPath $installRoot + installedExe = Test-Path -LiteralPath $installedExe + journalPath = Test-Path -LiteralPath $journalPath + uninstallExe = Test-Path -LiteralPath $uninstallExe + shortcutPath = Test-Path -LiteralPath $shortcutPath + registryPath = Test-Path -LiteralPath $registryPath + uninstallRegistryPath = Test-Path -LiteralPath $uninstallRegistryPath + } + + if (-not ($remaining.installRoot -or $remaining.installedExe -or $remaining.journalPath -or $remaining.uninstallExe -or $remaining.shortcutPath -or $remaining.registryPath -or $remaining.uninstallRegistryPath)) { + Write-TraceEvent -Phase "uninstall_cleanup_observed" -Detail $remaining + return + } + + if (((Get-Date) - $lastPoll).TotalSeconds -ge 5) { + $lastPoll = Get-Date + Write-TraceEvent -Phase "waiting_for_uninstall_cleanup" -Detail $remaining + } + + Start-Sleep -Seconds 1 + } + + throw "Uninstall cleanup did not complete within $TimeoutSeconds seconds." +} + $installRoot = Join-Path $env:LOCALAPPDATA "CovenantSetupSelfTest" $shortcutPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "Covenant Setup Self Test.lnk" $registryPath = "HKCU:\Software\CovenantSetupSelfTest" $journalPath = Join-Path $installRoot "journal.json" $installedExe = Join-Path $installRoot "bin\covenant-setup.exe" $uninstallExe = Join-Path $installRoot "covenant-setup-uninstall.exe" -$taskName = "{0}-{1}" -f $TaskNamePrefix, ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) +$uninstallRegistryPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Covenant_Setup_Self_Test" +$taskStamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() +$installTaskName = "{0}-Install-{1}" -f $TaskNamePrefix, $taskStamp +$uninstallTaskName = "{0}-Uninstall-{1}" -f $TaskNamePrefix, $taskStamp +$taskName = $installTaskName $interactiveScript = Join-Path $ScriptRoot "Invoke-InteractiveInstaller.ps1" +$installRunResultPath = $null +$uninstallRunResultPath = $null try { + Remove-Item -LiteralPath $TracePath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $TracePath | Out-Null + Write-TraceEvent -Phase "self_install_start" -Detail @{ + installerPath = $InstallerPath + resultPath = $ResultPath + tracePath = $TracePath + timeoutSeconds = $TimeoutSeconds + } + if (-not (Test-Path -LiteralPath $InstallerPath)) { throw "Installer not found in guest: $InstallerPath" } @@ -39,51 +334,24 @@ try { New-Item -ItemType Directory -Force -Path $resultDir | Out-Null } Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue + $installRunResultPath = Join-Path $resultDir "install-run-result.json" + $uninstallRunResultPath = Join-Path $resultDir "uninstall-run-result.json" + Remove-Item -LiteralPath $installRunResultPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $uninstallRunResultPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $shortcutPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $uninstallRegistryPath -Recurse -Force -ErrorAction SilentlyContinue + Write-TraceEvent -Phase "self_install_cleaned_previous_state" - $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) - $actionArguments = @( - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", (Quote-TaskArgument -Value $interactiveScript), - "-InstallerPath", (Quote-TaskArgument -Value $InstallerPath), - "-ResultPath", (Quote-TaskArgument -Value $ResultPath), - "-TimeoutSeconds", $TimeoutSeconds - ) -join " " - $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArguments - $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest - $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable - - Register-ScheduledTask ` - -TaskName $taskName ` - -Action $action ` - -Trigger $trigger ` - -Settings $settings ` - -Principal $principal ` - -Force | Out-Null - - Start-ScheduledTask -TaskName $taskName - - $deadline = (Get-Date).AddSeconds($TimeoutSeconds + 120) - while ((Get-Date) -lt $deadline) { - if (Test-Path -LiteralPath $ResultPath) { - break - } - Start-Sleep -Seconds 2 - } - - if (-not (Test-Path -LiteralPath $ResultPath)) { - $taskInfo = Get-ScheduledTaskInfo -TaskName $taskName - throw "Timed out waiting for interactive installer task. LastTaskResult=$($taskInfo.LastTaskResult)" - } - - $runResult = Get-Content -LiteralPath $ResultPath -Raw | ConvertFrom-Json - if (-not $runResult.success) { - throw "Interactive installer task failed: $($runResult.error)" - } + $installRunResult = Invoke-InteractiveOperation ` + -TaskName $installTaskName ` + -ExecutablePath $InstallerPath ` + -RunResultPath $installRunResultPath ` + -OperationName "install" ` + -Arguments @("--headed", "--automation") + Write-TraceEvent -Phase "verification_start" if (-not (Test-Path -LiteralPath $installedExe)) { throw "Installed executable missing: $installedExe" } @@ -114,34 +382,90 @@ try { throw "Journal did not record the packaged payload copy." } + $uninstallRunResult = Invoke-InteractiveOperation ` + -TaskName $uninstallTaskName ` + -ExecutablePath $uninstallExe ` + -RunResultPath $uninstallRunResultPath ` + -OperationName "uninstall" ` + -Arguments @("--headed", "--automation", "uninstall", $journalPath) + + Write-TraceEvent -Phase "uninstall_verification_start" + Wait-ForUninstallCleanup -TimeoutSeconds 60 + if (Test-Path -LiteralPath $installedExe) { + throw "Installed executable still exists after uninstall: $installedExe" + } + if (Test-Path -LiteralPath $journalPath) { + throw "Journal still exists after uninstall: $journalPath" + } + if (Test-Path -LiteralPath $uninstallExe) { + throw "Installed uninstaller still exists after uninstall: $uninstallExe" + } + if (Test-Path -LiteralPath $shortcutPath) { + throw "Desktop shortcut still exists after uninstall: $shortcutPath" + } + if (Test-Path -LiteralPath $registryPath) { + throw "Registry key still exists after uninstall: $registryPath" + } + if (Test-Path -LiteralPath $uninstallRegistryPath) { + throw "Installed Apps registry key still exists after uninstall: $uninstallRegistryPath" + } + if (Test-Path -LiteralPath $installRoot) { + throw "Install root still exists after uninstall: $installRoot" + } + $verification = [ordered]@{ - success = $true - exitCode = [int]$runResult.exitCode - installerPath = $InstallerPath - installRoot = $installRoot - installedExe = $installedExe - journalPath = $journalPath - uninstallExe = $uninstallExe - shortcutPath = $shortcutPath - registryPath = $registryPath + success = $true + exitCode = 0 + installExitCode = [int]$installRunResult.exitCode + uninstallExitCode = [int]$uninstallRunResult.exitCode + installerPath = $InstallerPath + installRoot = $installRoot + installedExe = $installedExe + journalPath = $journalPath + uninstallExe = $uninstallExe + shortcutPath = $shortcutPath + registryPath = $registryPath + uninstallRegistryPath = $uninstallRegistryPath + tracePath = $TracePath + installRunResultPath = $installRunResultPath + uninstallRunResultPath = $uninstallRunResultPath journalActionTypes = $journalActionTypes - taskName = $taskName - startedAt = $runResult.startedAt - finishedAt = $runResult.finishedAt + installTaskName = $installTaskName + uninstallTaskName = $uninstallTaskName + installStartedAt = $installRunResult.startedAt + installFinishedAt = $installRunResult.finishedAt + uninstallStartedAt = $uninstallRunResult.startedAt + uninstallFinishedAt = $uninstallRunResult.finishedAt + uninstallVerified = $true } $verification | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 + Write-TraceEvent -Phase "self_install_success" -Detail $verification } catch { + Write-TraceEvent -Phase "self_install_error" -Detail @{ + error = $_.Exception.Message + type = $_.Exception.GetType().FullName + } + Export-SmokeDiagnostics -Reason "self_install_error" $failure = [ordered]@{ - success = $false - error = $_.Exception.Message - taskName = $taskName - installRoot = $installRoot - resultPath = $ResultPath + success = $false + error = $_.Exception.Message + taskName = $taskName + installTaskName = $installTaskName + uninstallTaskName = $uninstallTaskName + installRoot = $installRoot + resultPath = $ResultPath + installRunResultPath = $installRunResultPath + uninstallRunResultPath = $uninstallRunResultPath + tracePath = $TracePath } $failure | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 exit 1 } finally { - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + foreach ($taskToRemove in @($installTaskName, $uninstallTaskName)) { + if ($taskToRemove) { + Unregister-ScheduledTask -TaskName $taskToRemove -Confirm:$false -ErrorAction SilentlyContinue + } + } } diff --git a/src/main.rs b/src/main.rs index 75c2dfd..11bc104 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ #![windows_subsystem = "windows"] +mod ui; mod win; use clap::{ArgAction, Parser, Subcommand}; @@ -20,6 +21,7 @@ use std::sync::{ use std::thread; use std::time::Duration; use thiserror::Error; +use ui::GuiProgress; const EXIT_ELEVATION_REQUIRED: i32 = 33; const EXIT_OPERATION_FAILED: i32 = 1; @@ -198,6 +200,18 @@ struct EmbeddedBundle { files: Vec, } +#[derive(Debug, Serialize, Deserialize)] +struct EmbeddedBundleIndex { + metadata: PackagedApp, + files: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct EmbeddedFileIndexEntry { + relative_path: String, + len: u64, +} + trait MutationTracker { fn record(&mut self, action: JournalAction); fn finish(self, app_name: String, manifest_path: Option, purge: PurgeSpec) -> Journal; @@ -385,70 +399,15 @@ impl Drop for TuiProgress { } } -struct GuiProgress { - state_path: PathBuf, - log_path: PathBuf, - total_steps: usize, -} - -impl GuiProgress { - fn start(title: &str, initial_message: &str, total_steps: usize) -> Result { - let root = std::env::temp_dir().join("covenant-setup-ui"); - fs::create_dir_all(&root)?; - let stamp = unique_ticks(); - let state_path = root.join(format!("state-{stamp}.json")); - let log_path = root.join(format!("log-{stamp}.txt")); - fs::write(&log_path, b"")?; - write_progress_state(&state_path, title, initial_message, 0, total_steps, false)?; - spawn_gui_progress_window(&state_path, &log_path, title)?; - Ok(Self { - state_path, - log_path, - total_steps, - }) - } - - fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError> { - append_progress_log(&self.log_path, message)?; - write_progress_state( - &self.state_path, - "", - message, - current_step, - self.total_steps, - false, - )?; - Ok(()) - } - - fn finish(&mut self, message: &str) -> Result<(), AppError> { - write_progress_state( - &self.state_path, - "", - message, - self.total_steps, - self.total_steps, - true, - )?; - Ok(()) - } -} - -impl Drop for GuiProgress { - fn drop(&mut self) { - let _ = write_progress_state( - &self.state_path, - "", - "Complete", - self.total_steps, - self.total_steps, - true, - ); - } -} - fn main() { let args: Vec<_> = std::env::args_os().collect(); + trace_event( + "process_start", + json!({ + "pid": process::id(), + "args": args.iter().map(|arg| arg.to_string_lossy().to_string()).collect::>() + }), + ); if is_bundled_runtime_invocation(&args) { let logger = Logger { json: false, @@ -460,7 +419,7 @@ fn main() { Ok(()) => 0, Err(AppError::Message(ref message)) if message == "__elevated_relaunch__" => 0, Err(err) => { - let _ = win::gui_report_error(&err.to_string(), &logger); + let _ = ui::report_error(&err.to_string()); logger.error(err, EXIT_OPERATION_FAILED); EXIT_OPERATION_FAILED } @@ -506,6 +465,7 @@ fn run(cli: Cli, logger: &Logger) -> Result<(), AppError> { &journal, cli.elevate, select_ui(UiPhase::Uninstall, preferences, logger)?, + preferences.automation, logger, ), Commands::Cleanup { @@ -517,6 +477,7 @@ fn run(cli: Cli, logger: &Logger) -> Result<(), AppError> { install_root, app_name, select_ui(UiPhase::Cleanup, preferences, logger)?, + preferences.automation, logger, ), } @@ -614,7 +575,35 @@ fn collect_bundle_files_recursive( } fn append_embedded_bundle(exe_target: &Path, bundle: &EmbeddedBundle) -> Result<(), AppError> { - let payload = serde_json::to_vec(bundle)?; + let index = EmbeddedBundleIndex { + metadata: PackagedApp { + app_name: bundle.metadata.app_name.clone(), + manifest: bundle.metadata.manifest.clone(), + }, + files: bundle + .files + .iter() + .map(|file| EmbeddedFileIndexEntry { + relative_path: file.relative_path.clone(), + len: file.data.len() as u64, + }) + .collect(), + }; + let index_bytes = serde_json::to_vec(&index)?; + let mut payload = Vec::with_capacity( + std::mem::size_of::() + + index_bytes.len() + + bundle + .files + .iter() + .map(|file| file.data.len()) + .sum::(), + ); + payload.write_all(&(index_bytes.len() as u64).to_le_bytes())?; + payload.write_all(&index_bytes)?; + for file in &bundle.files { + payload.write_all(&file.data)?; + } let mut file = fs::OpenOptions::new().append(true).open(exe_target)?; file.write_all(&payload)?; file.write_all(&(payload.len() as u64).to_le_bytes())?; @@ -646,7 +635,54 @@ fn read_embedded_bundle(exe_path: &Path) -> Result, AppEr )); } let payload_offset = size_offset - payload_len; - let bundle: EmbeddedBundle = serde_json::from_slice(&bytes[payload_offset..size_offset])?; + let payload = &bytes[payload_offset..size_offset]; + if payload.len() < std::mem::size_of::() { + return Err(AppError::Message("Embedded payload is too short".into())); + } + let index_len = u64::from_le_bytes( + payload[..std::mem::size_of::()] + .try_into() + .map_err(|_| AppError::Message("Invalid embedded index length".into()))?, + ) as usize; + let index_offset = std::mem::size_of::(); + let data_offset = index_offset + .checked_add(index_len) + .ok_or_else(|| AppError::Message("Embedded index length overflow".into()))?; + if data_offset > payload.len() { + return Err(AppError::Message( + "Embedded index length exceeds payload size".into(), + )); + } + let index: EmbeddedBundleIndex = serde_json::from_slice(&payload[index_offset..data_offset])?; + let EmbeddedBundleIndex { + metadata, + files: index_files, + } = index; + let mut cursor = data_offset; + let mut files = Vec::with_capacity(index_files.len()); + for entry in index_files { + let len = entry.len as usize; + let end = cursor + .checked_add(len) + .ok_or_else(|| AppError::Message("Embedded file length overflow".into()))?; + if end > payload.len() { + return Err(AppError::Message(format!( + "Embedded file exceeds payload size: {}", + entry.relative_path + ))); + } + files.push(EmbeddedFile { + relative_path: entry.relative_path, + data: payload[cursor..end].to_vec(), + }); + cursor = end; + } + if cursor != payload.len() { + return Err(AppError::Message( + "Embedded payload has trailing bytes after file data".into(), + )); + } + let bundle = EmbeddedBundle { metadata, files }; Ok(Some(bundle)) } @@ -687,36 +723,45 @@ fn run_bundled_installer( preferences: UiPreferences, logger: &Logger, ) -> Result<(), AppError> { + trace_event("bundled_installer_start", json!({})); let exe = std::env::current_exe()?; let bundle = read_embedded_bundle(&exe)? .ok_or_else(|| AppError::Message("No embedded package found in installer".into()))?; let extraction_root = extract_embedded_bundle(&exe, &bundle)?; + trace_event( + "bundled_installer_extracted", + json!({"exe": exe, "extraction_root": extraction_root}), + ); let metadata = bundle.metadata; let manifest_path = extraction_root.join(metadata.manifest.clone()); - let journal_path = exe - .parent() - .ok_or_else(|| AppError::Message("Packaged installer has no parent directory".into()))? - .join("journal.json"); - match mode { RuntimeMode::Bundled => { let ui_mode = select_ui(UiPhase::Install, preferences, 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 - && !win::gui_confirm_install(&metadata.app_name, logger)? + && !ui::confirm_install(&metadata.app_name)? { return Ok(()); } - match install(&manifest_path, Some(journal_path), true, ui_mode, logger) { + match install(&manifest_path, None, true, ui_mode, logger) { Ok(()) => { + trace_event("bundled_installer_install_ok", json!({})); if ui_mode == UiMode::Gui && !preferences.automation { - win::gui_report_success(&metadata.app_name, logger)?; + 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 { - win::gui_report_error(&err.to_string(), logger)?; + ui::report_error(&err.to_string())?; } Err(err) } @@ -733,6 +778,10 @@ fn install( logger: &Logger, ) -> Result<(), AppError> { let manifest: InstallManifest = toml::from_str(&fs::read_to_string(manifest_path)?)?; + trace_event( + "install_start", + json!({"manifest": manifest_path, "app_name": &manifest.app_name}), + ); let app_name = manifest.app_name.clone(); let _progress = start_tui_progress(ui_mode, format!("Installing {} ", manifest.app_name)); let mut gui_progress = start_gui_progress( @@ -750,6 +799,10 @@ 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)?; + trace_event( + "install_elevation_checked", + json!({"requires_admin": requires_admin, "elevate": elevate}), + ); let runtime = build_install_runtime( &manifest, manifest_path, @@ -932,6 +985,10 @@ fn install( fs::create_dir_all(parent)?; } fs::write(&runtime.journal_path, serde_json::to_vec_pretty(&journal)?)?; + trace_event( + "install_journal_written", + json!({"journal": runtime.journal_path, "actions": journal.actions.len()}), + ); effective_logger.result( "ok", json!({"journal":runtime.journal_path,"actions":journal.actions.len()}), @@ -947,6 +1004,10 @@ fn install( })(); if let Err(err) = &result { + trace_event( + "install_error", + json!({"app_name": app_name, "error": err.to_string()}), + ); let _ = fail_gui_progress( &mut gui_progress, &format!("{app_name} installation failed: {err}"), @@ -960,9 +1021,14 @@ fn uninstall( journal_path: &Path, elevate: bool, ui_mode: UiMode, + automation: bool, logger: &Logger, ) -> Result<(), AppError> { let journal: Journal = serde_json::from_str(&fs::read_to_string(journal_path)?)?; + trace_event( + "uninstall_start", + json!({"journal": journal_path, "app_name": &journal.app_name}), + ); 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( @@ -980,6 +1046,10 @@ 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)?; + trace_event( + "uninstall_elevation_checked", + json!({"requires_admin": requires_admin, "elevate": elevate}), + ); let current_exe = std::env::current_exe().ok(); let mut deferred_self_delete: Option = None; let mut deferred_uninstall_registry: Vec<(RegistryRoot, String)> = Vec::new(); @@ -1082,6 +1152,7 @@ fn uninstall( path.parent(), &journal.app_name, ui_mode, + automation, &effective_logger, )?; } else { @@ -1089,16 +1160,21 @@ fn uninstall( &mut gui_progress, &format!("{} uninstalled successfully!", journal.app_name), )?; - if ui_mode == UiMode::Gui { - win::gui_report_uninstall_success(&journal.app_name, &effective_logger)?; + if ui_mode == UiMode::Gui && !automation { + ui::report_uninstall_success(&journal.app_name)?; } } effective_logger.result("ok", json!({"journal":journal_path})); + trace_event("uninstall_ok", json!({"journal": journal_path})); Ok(()) })(); if let Err(err) = &result { + trace_event( + "uninstall_error", + json!({"app_name": app_name, "error": err.to_string()}), + ); let _ = fail_gui_progress( &mut gui_progress, &format!("{app_name} uninstall failed: {err}"), @@ -1113,8 +1189,13 @@ fn cleanup( install_root: Option, app_name: String, ui_mode: UiMode, + automation: bool, logger: &Logger, ) -> Result<(), AppError> { + trace_event( + "cleanup_start", + json!({"target_exe": &target_exe, "install_root": &install_root, "app_name": &app_name}), + ); let effective_logger = if ui_mode == UiMode::Tui { logger.quiet_clone() } else { @@ -1140,13 +1221,13 @@ fn cleanup( } } reboot_required |= schedule_helper_self_cleanup(&effective_logger)?; - if ui_mode == UiMode::Gui { + if ui_mode == UiMode::Gui && !automation { if reboot_required { - if win::gui_prompt_uninstall_reboot(&app_name, &effective_logger)? { + if ui::prompt_uninstall_reboot(&app_name)? { spawn_reboot(&effective_logger)?; } } else { - win::gui_report_uninstall_success(&app_name, &effective_logger)?; + ui::report_uninstall_success(&app_name)?; } } else if ui_mode == UiMode::Tui { if reboot_required { @@ -1169,12 +1250,18 @@ fn ensure_elevation_if_needed( logger: &Logger, ) -> Result<(), AppError> { if !required || win::is_elevated(logger)? { + trace_event( + "elevation_ok", + json!({"required": required, "relaunch": relaunch}), + ); return Ok(()); } if relaunch { + trace_event("elevation_relaunch", json!({})); win::relaunch_as_admin(logger)?; return Err(AppError::Message("__elevated_relaunch__".into())); } + trace_event("elevation_required_error", json!({})); Err(AppError::Message( "Elevation required for requested operation".into(), )) @@ -1297,6 +1384,7 @@ fn spawn_cleanup_helper( install_root: Option<&Path>, app_name: &str, ui_mode: UiMode, + automation: bool, logger: &Logger, ) -> Result<(), AppError> { let current_exe = std::env::current_exe()?; @@ -1315,6 +1403,11 @@ fn spawn_cleanup_helper( command.creation_flags(CREATE_NO_WINDOW); if ui_mode == UiMode::Tui { command.arg("--headless"); + } else if ui_mode == UiMode::Gui { + command.arg("--headed"); + } + if automation { + command.arg("--automation"); } command.arg("cleanup"); command.arg("--target-exe"); @@ -1401,14 +1494,31 @@ fn select_ui( }) } +fn ui_mode_name(ui_mode: UiMode) -> &'static str { + match ui_mode { + UiMode::None => "none", + UiMode::Gui => "gui", + UiMode::Tui => "tui", + } +} + fn start_gui_progress( ui_mode: UiMode, title: &str, app_name: &str, total_steps: usize, ) -> 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 ui_mode == UiMode::Gui { - Ok(Some(GuiProgress::start( + Ok(Some(ui::GuiProgress::start( title, &format!("{title}"), total_steps.max(1), @@ -1424,6 +1534,10 @@ fn advance_gui_progress( current_step: usize, message: &str, ) -> Result<(), AppError> { + trace_event( + "progress", + json!({"current_step": current_step, "message": message}), + ); if let Some(progress) = gui_progress.as_mut() { progress.advance(current_step, message)?; } @@ -1434,6 +1548,7 @@ fn finish_gui_progress( gui_progress: &mut Option, message: &str, ) -> Result<(), AppError> { + trace_event("progress_finish", json!({"message": message})); if let Some(progress) = gui_progress.as_mut() { progress.finish(message)?; } @@ -1444,6 +1559,7 @@ fn fail_gui_progress( gui_progress: &mut Option, message: &str, ) -> Result<(), AppError> { + trace_event("progress_fail", json!({"message": message})); if let Some(progress) = gui_progress.as_mut() { progress.finish(message)?; } @@ -1460,7 +1576,8 @@ fn append_gui_shell_output( if let Some(progress) = gui_progress.as_mut() { let text = String::from_utf8_lossy(bytes); for line in text.lines().filter(|line| !line.trim().is_empty()) { - append_progress_log(&progress.log_path, line)?; + trace_event("script_output", json!({"line": line})); + progress.log(line)?; } } Ok(()) @@ -1500,121 +1617,32 @@ fn schedule_helper_self_cleanup(logger: &Logger) -> Result { Ok(true) } -fn write_progress_state( - state_path: &Path, - title: &str, - message: &str, - current_step: usize, - total_steps: usize, - complete: bool, -) -> Result<(), AppError> { - let progress = if total_steps == 0 { - 0 - } else { - ((current_step.min(total_steps) * 100) / total_steps) as u64 +pub(crate) fn trace_event(phase: &str, detail: impl Serialize) { + let Ok(root) = std::env::var("COVENANT_SETUP_TRACE_DIR") else { + return; }; - fs::write( - state_path, - serde_json::to_vec(&json!({ - "title": title, - "message": message, - "progress": progress, - "complete": complete - }))?, - )?; - Ok(()) -} + if root.trim().is_empty() { + return; + } -fn append_progress_log(log_path: &Path, line: &str) -> Result<(), AppError> { - let mut file = fs::OpenOptions::new().append(true).open(log_path)?; - writeln!(file, "{line}")?; - Ok(()) -} - -fn spawn_gui_progress_window( - state_path: &Path, - log_path: &Path, - title: &str, -) -> Result<(), AppError> { - let state_path_ps = powershell_single_quote(&state_path.to_string_lossy()); - let log_path_ps = powershell_single_quote(&log_path.to_string_lossy()); - let title_ps = powershell_single_quote(title); - let script = format!(r#" -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing -$statePath = '{state_path_ps}' -$logPath = '{log_path_ps}' -$windowTitle = '{title_ps}' -$form = New-Object Windows.Forms.Form -$form.Text = $windowTitle -$form.Size = New-Object Drawing.Size(720,420) -$form.StartPosition = 'CenterScreen' -$label = New-Object Windows.Forms.Label -$label.Location = New-Object Drawing.Point(12,12) -$label.Size = New-Object Drawing.Size(680,24) -$label.Text = $windowTitle -$bar = New-Object Windows.Forms.ProgressBar -$bar.Location = New-Object Drawing.Point(12,44) -$bar.Size = New-Object Drawing.Size(680,24) -$bar.Minimum = 0 -$bar.Maximum = 100 -$output = New-Object Windows.Forms.TextBox -$output.Location = New-Object Drawing.Point(12,80) -$output.Size = New-Object Drawing.Size(680,288) -$output.Multiline = $true -$output.ScrollBars = 'Vertical' -$output.ReadOnly = $true -$output.Font = New-Object Drawing.Font('Consolas',9) -$timer = New-Object Windows.Forms.Timer -$timer.Interval = 250 -$timer.Add_Tick(({{ - try {{ - if (Test-Path $statePath) {{ - $state = Get-Content -LiteralPath $statePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - if ($state.title) {{ $form.Text = $state.title }} - $label.Text = $state.message - $bar.Value = [Math]::Max(0, [Math]::Min(100, [int]$state.progress)) - if ([bool]$state.complete) {{ - $timer.Stop() - $form.Close() - }} - }} - }} catch {{ - # Ignore transient reads while Rust updates the state file. - }} - try {{ - if (Test-Path $logPath) {{ - $text = Get-Content -LiteralPath $logPath -Raw -ErrorAction Stop - if ($output.Text -ne $text) {{ - $output.Text = $text - $output.SelectionStart = $output.Text.Length - $output.ScrollToCaret() - }} - }} - }} catch {{ - # Ignore transient reads while Rust updates the log file. - }} -}}).GetNewClosure()) -$form.Controls.Add($label) -$form.Controls.Add($bar) -$form.Controls.Add($output) -$timer.Start() -[void]$form.ShowDialog() -"#); - let script_path = state_path.with_extension("ps1"); - fs::write(&script_path, &script)?; - let mut command = Command::new("powershell.exe"); - command.creation_flags(CREATE_NO_WINDOW); - command.arg("-STA"); - command.arg("-NoProfile"); - command.arg("-ExecutionPolicy"); - command.arg("Bypass"); - command.arg("-WindowStyle"); - command.arg("Hidden"); - command.arg("-File"); - command.arg(&script_path); - command.spawn()?; - Ok(()) + let root = PathBuf::from(root); + if fs::create_dir_all(&root).is_err() { + return; + } + let path = root.join(format!("installer-heartbeat-{}.jsonl", process::id())); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let event = json!({ + "time_unix_ms": timestamp, + "pid": process::id(), + "phase": phase, + "detail": detail + }); + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{event}"); + } } fn unique_ticks() -> u128 { @@ -1735,6 +1763,10 @@ fn execute_script( working_directory: Option<&Path>, gui_progress: &mut Option, ) -> Result<(), AppError> { + trace_event( + "script_start", + json!({"command": &script.command, "args": &script.args, "working_directory": working_directory}), + ); let command_path = absolutize(manifest_dir, &script.command); let command = if command_path.exists() { command_path @@ -1751,6 +1783,10 @@ fn execute_script( append_gui_shell_output(gui_progress, &output.stdout)?; append_gui_shell_output(gui_progress, &output.stderr)?; let status = output.status; + trace_event( + "script_exit", + json!({"command": &script.command, "status": status.code()}), + ); if !status.success() { return Err(AppError::Message(format!( "Script failed: {} ({status})", diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..6681337 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,344 @@ +use crate::AppError; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::os::windows::process::CommandExt; +use std::path::PathBuf; +use std::process::{self, Child, Command}; +use std::thread; +use std::time::{Duration, Instant}; + +const CREATE_NO_WINDOW: u32 = 0x0800_0000; +const UI_EXE_BYTES: &[u8] = include_bytes!(env!("COVENANT_SETUP_UI_EXE")); + +pub struct GuiProgress { + session: CSharpUiSession, + total_steps: usize, +} + +impl GuiProgress { + pub fn start(title: &str, initial_message: &str, total_steps: usize) -> Result { + let mut session = CSharpUiSession::start()?; + session.send(&json!({ + "type": "init", + "title": title, + "message": initial_message, + "total_steps": total_steps.max(1), + }))?; + Ok(Self { + session, + total_steps: total_steps.max(1), + }) + } + + pub fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "progress", + "current_step": current_step, + "total_steps": self.total_steps, + "message": message, + })) + } + + pub fn log(&mut self, message: &str) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "log", + "message": message, + })) + } + + pub fn finish(&mut self, message: &str) -> Result<(), AppError> { + self.session.send(&json!({ + "type": "finish", + "message": message, + })) + } +} + +pub fn confirm_install(app_name: &str) -> Result { + crate::trace_event("ui_prompt_confirm_install", json!({"app_name": app_name})); + let result = prompt( + "covenant-setup", + &format!("Install {app_name} now?"), + PromptButtons::OkCancel, + PromptIcon::Information, + )?; + Ok(matches!(result, PromptResult::Ok)) +} + +pub fn report_success(app_name: &str) -> Result<(), AppError> { + crate::trace_event("ui_prompt_report_success", json!({"app_name": app_name})); + let _ = prompt( + "covenant-setup", + &format!("{app_name} installation completed successfully"), + PromptButtons::Ok, + PromptIcon::Information, + )?; + Ok(()) +} + +pub fn report_error(message: &str) -> Result<(), AppError> { + crate::trace_event("ui_prompt_report_error", json!({"message": message})); + let _ = prompt( + "covenant-setup", + message, + PromptButtons::Ok, + PromptIcon::Error, + )?; + Ok(()) +} + +pub fn report_uninstall_success(app_name: &str) -> Result<(), AppError> { + crate::trace_event( + "ui_prompt_report_uninstall_success", + json!({"app_name": app_name}), + ); + let _ = prompt( + "covenant-setup", + &format!("{app_name} uninstalled successfully!"), + PromptButtons::Ok, + PromptIcon::Information, + )?; + Ok(()) +} + +pub fn prompt_uninstall_reboot(app_name: &str) -> Result { + crate::trace_event("ui_prompt_uninstall_reboot", json!({"app_name": app_name})); + let result = prompt( + "covenant-setup", + &format!( + "{app_name} uninstalled sucessfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now." + ), + PromptButtons::YesNo, + PromptIcon::Information, + )?; + Ok(matches!(result, PromptResult::Yes)) +} + +fn prompt( + title: &str, + message: &str, + buttons: PromptButtons, + icon: PromptIcon, +) -> Result { + let mut session = CSharpUiSession::start()?; + let id = format!("prompt-{}", unique_suffix()); + session.send(&json!({ + "type": "prompt", + "id": id, + "title": title, + "message": message, + "buttons": buttons.as_str(), + "icon": icon.as_str(), + }))?; + let response: PromptResponse = session.read()?; + if response.message_type != "prompt_response" || response.id.as_deref() != Some(&id) { + return Err(AppError::Message("Unexpected UI prompt response".into())); + } + let result = PromptResult::from_str(response.result.as_deref().unwrap_or("none"))?; + crate::trace_event( + "ui_prompt_response", + json!({"id": id, "result": response.result}), + ); + Ok(result) +} + +enum PromptButtons { + Ok, + OkCancel, + YesNo, +} + +impl PromptButtons { + fn as_str(&self) -> &'static str { + match self { + Self::Ok => "ok", + Self::OkCancel => "ok_cancel", + Self::YesNo => "yes_no", + } + } +} + +enum PromptIcon { + Information, + Error, +} + +impl PromptIcon { + fn as_str(&self) -> &'static str { + match self { + Self::Information => "information", + Self::Error => "error", + } + } +} + +enum PromptResult { + Ok, + Cancel, + Yes, + No, + None, +} + +impl PromptResult { + fn from_str(value: &str) -> Result { + match value { + "ok" => Ok(Self::Ok), + "cancel" => Ok(Self::Cancel), + "yes" => Ok(Self::Yes), + "no" => Ok(Self::No), + "none" => Ok(Self::None), + other => Err(AppError::Message(format!( + "Unknown UI prompt response: {other}" + ))), + } + } +} + +struct CSharpUiSession { + child: Child, + reader: BufReader, + writer: fs::File, + exe_path: PathBuf, + closed: 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()?; + crate::trace_event("ui_extracted", json!({"exe_path": &exe_path})); + let mut child = Command::new(&exe_path) + .creation_flags(CREATE_NO_WINDOW) + .arg("--pipe") + .arg(&pipe_name) + .spawn()?; + crate::trace_event( + "ui_spawned", + json!({"pid": child.id(), "exe_path": &exe_path}), + ); + let pipe_path = format!(r"\\.\pipe\{pipe_name}"); + let pipe = connect_pipe(&pipe_path, &mut child)?; + crate::trace_event("ui_pipe_connected", json!({"pipe_path": pipe_path})); + let writer = pipe.try_clone()?; + Ok(Self { + child, + reader: BufReader::new(pipe), + writer, + exe_path, + closed: false, + }) + } + + fn send(&mut self, value: &T) -> Result<(), AppError> { + let value = serde_json::to_value(value)?; + crate::trace_event("ui_pipe_send", message_summary(&value)); + let bytes = serde_json::to_vec(&value)?; + self.writer.write_all(&bytes)?; + self.writer.write_all(b"\n")?; + self.writer.flush()?; + Ok(()) + } + + fn read Deserialize<'de>>(&mut self) -> Result { + let mut line = String::new(); + let bytes = self.reader.read_line(&mut line)?; + if bytes == 0 { + return Err(AppError::Message("UI pipe closed before response".into())); + } + let value: Value = serde_json::from_str(&line)?; + crate::trace_event("ui_pipe_receive", message_summary(&value)); + Ok(serde_json::from_value(value)?) + } +} + +impl Drop for CSharpUiSession { + fn drop(&mut self) { + if !self.closed { + crate::trace_event("ui_close_send", json!({"pid": self.child.id()})); + let _ = self.send(&json!({"type": "close"})); + self.closed = true; + } + for _ in 0..20 { + if self.child.try_wait().ok().flatten().is_some() { + crate::trace_event("ui_exited", json!({"pid": self.child.id()})); + let _ = fs::remove_file(&self.exe_path); + return; + } + thread::sleep(Duration::from_millis(50)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + crate::trace_event("ui_killed", json!({"pid": self.child.id()})); + let _ = fs::remove_file(&self.exe_path); + } +} + +#[derive(Deserialize)] +struct PromptResponse { + #[serde(rename = "type")] + message_type: String, + id: Option, + result: Option, +} + +fn connect_pipe(pipe_path: &str, child: &mut Child) -> Result { + let deadline = Instant::now() + Duration::from_secs(15); + crate::trace_event("ui_pipe_connect_wait", json!({"pipe_path": pipe_path})); + loop { + match OpenOptions::new().read(true).write(true).open(pipe_path) { + Ok(file) => return Ok(file), + Err(err) => { + if let Some(status) = child.try_wait()? { + crate::trace_event( + "ui_pipe_connect_child_exited", + json!({"pipe_path": pipe_path, "status": status.to_string()}), + ); + return Err(AppError::Message(format!( + "C# UI exited before pipe connection: {status}" + ))); + } + if Instant::now() >= deadline { + crate::trace_event( + "ui_pipe_connect_timeout", + json!({"pipe_path": pipe_path, "error": err.to_string()}), + ); + return Err(AppError::Message(format!( + "Timed out connecting to C# UI pipe {pipe_path}: {err}" + ))); + } + thread::sleep(Duration::from_millis(50)); + } + } + } +} + +fn extract_ui_exe() -> Result { + let root = std::env::temp_dir().join("covenant-setup-ui"); + fs::create_dir_all(&root)?; + let path = root.join(format!( + "Covenant.Setup.Ui-{}-{}.exe", + process::id(), + unique_suffix() + )); + fs::write(&path, UI_EXE_BYTES)?; + Ok(path) +} + +fn message_summary(value: &Value) -> Value { + json!({ + "type": value.get("type").and_then(Value::as_str), + "id": value.get("id").and_then(Value::as_str), + "message": value.get("message").and_then(Value::as_str), + }) +} + +fn unique_suffix() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} diff --git a/src/win.rs b/src/win.rs index b6cf52a..e89e375 100644 --- a/src/win.rs +++ b/src/win.rs @@ -29,15 +29,11 @@ use windows::Win32::System::RestartManager::{ RM_PROCESS_INFO, RmEndSession, RmGetList, RmRegisterResources, RmStartSession, }; use windows::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, OpenProcessToken}; -use windows::Win32::UI::Controls::{ - TASKDIALOG_COMMON_BUTTON_FLAGS, TDCBF_CANCEL_BUTTON, TDCBF_NO_BUTTON, TDCBF_OK_BUTTON, - TDCBF_YES_BUTTON, TaskDialog, -}; use windows::Win32::UI::Shell::{ FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64, IShellLinkW, KNOWN_FOLDER_FLAG, SHGetKnownFolderPath, ShellExecuteW, ShellLink, }; -use windows::Win32::UI::WindowsAndMessaging::{IDOK, IDYES, SW_SHOW}; +use windows::Win32::UI::WindowsAndMessaging::SW_SHOW; use windows::core::{Interface, PCWSTR, PWSTR, w}; pub struct PathResolver { @@ -188,90 +184,6 @@ pub fn relaunch_as_admin(logger: &Logger) -> Result<(), AppError> { Ok(()) } -pub fn message_box( - title: &str, - body: &str, - buttons: TASKDIALOG_COMMON_BUTTON_FLAGS, - icon: PCWSTR, - logger: &Logger, -) -> Result { - let mut button = 0i32; - let title_w = Utf16Arg::from_str(title); - let body_w = Utf16Arg::from_str(body); - logger.unsafe_enter("TaskDialog", json!({"title":title})); - unsafe { - TaskDialog( - Some(HWND::default()), - None, - PCWSTR(title_w.as_ptr()), - PCWSTR::null(), - PCWSTR(body_w.as_ptr()), - buttons, - icon, - Some(&mut button), - )? - }; - logger.unsafe_exit("TaskDialog", json!({"result": button})); - Ok(button) -} - -pub fn gui_confirm_install(app_name: &str, logger: &Logger) -> Result { - let result = message_box( - "covenant-setup", - &format!("Install {app_name} now?"), - TDCBF_OK_BUTTON | TDCBF_CANCEL_BUTTON, - td_information_icon(), - logger, - )?; - Ok(result == IDOK.0) -} - -pub fn gui_report_success(app_name: &str, logger: &Logger) -> Result<(), AppError> { - let _ = message_box( - "covenant-setup", - &format!("{app_name} installation completed successfully"), - TDCBF_OK_BUTTON, - td_information_icon(), - logger, - )?; - Ok(()) -} - -pub fn gui_report_error(message: &str, logger: &Logger) -> Result<(), AppError> { - let _ = message_box( - "covenant-setup", - message, - TDCBF_OK_BUTTON, - td_error_icon(), - logger, - )?; - Ok(()) -} - -pub fn gui_report_uninstall_success(app_name: &str, logger: &Logger) -> Result<(), AppError> { - let _ = message_box( - "covenant-setup", - &format!("{app_name} uninstalled successfully!"), - TDCBF_OK_BUTTON, - td_information_icon(), - logger, - )?; - Ok(()) -} - -pub fn gui_prompt_uninstall_reboot(app_name: &str, logger: &Logger) -> Result { - let result = message_box( - "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." - ), - TDCBF_YES_BUTTON | TDCBF_NO_BUTTON, - td_information_icon(), - logger, - )?; - Ok(result == IDYES.0) -} - pub fn create_directory_recursive(path: &Path, logger: &Logger) -> Result<(), AppError> { if path.as_os_str().is_empty() || path.exists() { return Ok(()); @@ -614,14 +526,6 @@ fn root_hkey(root: RegistryRoot) -> HKEY { } } -fn td_information_icon() -> PCWSTR { - PCWSTR(std::ptr::without_provenance(0xFFFD)) -} - -fn td_error_icon() -> PCWSTR { - PCWSTR(std::ptr::without_provenance(0xFFFE)) -} - fn win32_ok(status: WIN32_ERROR, operation: &str) -> Result<(), AppError> { if status == ERROR_SUCCESS { Ok(()) diff --git a/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj b/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj new file mode 100644 index 0000000..9ee17f8 --- /dev/null +++ b/ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj @@ -0,0 +1,12 @@ + + + WinExe + net8.0-windows + true + enable + enable + Covenant.Setup.Ui + Covenant.Setup.Ui + app.manifest + + diff --git a/ui/Covenant.Setup.Ui/Program.cs b/ui/Covenant.Setup.Ui/Program.cs new file mode 100644 index 0000000..1540b34 --- /dev/null +++ b/ui/Covenant.Setup.Ui/Program.cs @@ -0,0 +1,445 @@ +using System.IO.Pipes; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Covenant.Setup.Ui; + +internal static class Program +{ + [STAThread] + private static void Main(string[] args) + { + var pipeName = ReadPipeName(args); + UiTrace.Write("process_start", new { ProcessId = Environment.ProcessId, PipeName = pipeName }); + if (string.IsNullOrWhiteSpace(pipeName)) + { + UiTrace.Write("missing_pipe_argument"); + MessageBox.Show("Missing named pipe argument.", "covenant-setup", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new InstallerUiForm(pipeName)); + } + + private static string? ReadPipeName(string[] args) + { + for (var i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--pipe", StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + + return null; + } +} + +internal sealed class InstallerUiForm : Form +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly string _pipeName; + private readonly Label _statusLabel; + private readonly ProgressBar _progressBar; + private readonly TextBox _logBox; + private readonly Button _closeButton; + private StreamWriter? _writer; + private readonly object _writerLock = new(); + private bool _closeRequested; + + public InstallerUiForm(string pipeName) + { + _pipeName = pipeName; + + Text = "covenant-setup"; + StartPosition = FormStartPosition.CenterScreen; + ClientSize = new Size(720, 420); + MinimumSize = new Size(560, 320); + Font = new Font("Segoe UI", 9F); + + _statusLabel = new Label + { + AutoEllipsis = true, + Location = new Point(12, 12), + Size = new Size(ClientSize.Width - 24, 24), + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, + Text = "Preparing..." + }; + + _progressBar = new ProgressBar + { + Location = new Point(12, 44), + Size = new Size(ClientSize.Width - 24, 24), + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, + Minimum = 0, + Maximum = 100 + }; + + _logBox = new TextBox + { + Location = new Point(12, 80), + Size = new Size(ClientSize.Width - 24, ClientSize.Height - 128), + Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right, + Multiline = true, + ScrollBars = ScrollBars.Vertical, + ReadOnly = true, + Font = new Font("Consolas", 9F) + }; + + _closeButton = new Button + { + Text = "Close", + Enabled = false, + Size = new Size(88, 28), + Location = new Point(ClientSize.Width - 100, ClientSize.Height - 40), + Anchor = AnchorStyles.Bottom | AnchorStyles.Right + }; + _closeButton.Click += (_, _) => Close(); + + Controls.Add(_statusLabel); + Controls.Add(_progressBar); + Controls.Add(_logBox); + Controls.Add(_closeButton); + + Shown += (_, _) => _ = Task.Run(RunPipeLoop); + FormClosing += (_, args) => + { + if (!_closeButton.Enabled && !_closeRequested) + { + args.Cancel = true; + } + }; + } + + private void RunPipeLoop() + { + try + { + UiTrace.Write("pipe_server_create", new { PipeName = _pipeName }); + using var pipe = new NamedPipeServerStream( + _pipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.None); + UiTrace.Write("pipe_wait_for_connection", new { PipeName = _pipeName }); + pipe.WaitForConnection(); + UiTrace.Write("pipe_connected", new { PipeName = _pipeName }); + + using var reader = new StreamReader(pipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), bufferSize: 4096, leaveOpen: true) + { + AutoFlush = true, + NewLine = "\n" + }; + + lock (_writerLock) + { + _writer = writer; + } + + string? line; + while ((line = reader.ReadLine()) is not null) + { + UiTrace.Write("pipe_receive", SafeMessageSummary(line)); + if (!HandleMessage(line)) + { + break; + } + } + } + catch (Exception ex) + { + UiTrace.Write("pipe_error", new { ex.Message, ex.GetType().FullName, ex.StackTrace }); + BeginInvokeSafe(() => + { + AppendLog("UI pipe error: " + ex.Message); + _closeButton.Enabled = true; + }); + } + finally + { + lock (_writerLock) + { + _writer = null; + } + UiTrace.Write("pipe_loop_exit"); + } + } + + private bool HandleMessage(string line) + { + var message = JsonSerializer.Deserialize(line, JsonOptions); + if (message?.Type is null) + { + return true; + } + + switch (message.Type) + { + case "init": + BeginInvokeSafe(() => + { + Text = message.Title ?? "covenant-setup"; + _statusLabel.Text = message.Message ?? Text; + _progressBar.Value = 0; + }); + return true; + + case "progress": + BeginInvokeSafe(() => ApplyProgress(message)); + return true; + + case "log": + BeginInvokeSafe(() => AppendLog(message.Message ?? string.Empty)); + return true; + + case "finish": + UiTrace.Write("finish_message", new { message.Message }); + BeginInvokeSafe(() => + { + _statusLabel.Text = message.Message ?? "Complete"; + _progressBar.Value = 100; + _closeButton.Enabled = true; + _closeRequested = true; + Close(); + }); + return true; + + case "prompt": + UiTrace.Write("prompt_show_requested", new { message.Id, message.Title, message.Buttons, message.Icon }); + var result = ShowPrompt(message); + UiTrace.Write("prompt_response", new { message.Id, Result = result }); + WriteResponse(new UiResponse + { + Type = "prompt_response", + Id = message.Id, + Result = result + }); + return true; + + case "close": + UiTrace.Write("close_message"); + BeginInvokeSafe(() => + { + _closeRequested = true; + Close(); + }); + return false; + + default: + return true; + } + } + + private void ApplyProgress(UiMessage message) + { + if (!string.IsNullOrWhiteSpace(message.Message)) + { + _statusLabel.Text = message.Message; + AppendLog(message.Message); + } + + var total = Math.Max(1, message.TotalSteps ?? 1); + var current = Math.Max(0, Math.Min(total, message.CurrentStep ?? 0)); + _progressBar.Value = Math.Max(0, Math.Min(100, current * 100 / total)); + } + + private string ShowPrompt(UiMessage message) + { + if (InvokeRequired) + { + 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 result = MessageBox.Show( + this, + message.Message ?? string.Empty, + message.Title ?? "covenant-setup", + buttons, + 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" + }; + } + + private void WriteResponse(UiResponse response) + { + lock (_writerLock) + { + _writer?.WriteLine(JsonSerializer.Serialize(response, JsonOptions)); + UiTrace.Write("pipe_send", new { response.Type, response.Id, response.Result }); + } + } + + private void BeginInvokeSafe(Action action) + { + if (IsDisposed) + { + return; + } + + try + { + BeginInvoke(action); + } + catch (InvalidOperationException) + { + } + } + + private void AppendLog(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return; + } + + if (_logBox.TextLength > 0) + { + _logBox.AppendText(Environment.NewLine); + } + _logBox.AppendText(line); + _logBox.SelectionStart = _logBox.TextLength; + _logBox.ScrollToCaret(); + } + + private static object SafeMessageSummary(string line) + { + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + return new + { + Type = root.TryGetProperty("type", out var type) ? type.GetString() : null, + Id = root.TryGetProperty("id", out var id) ? id.GetString() : null, + Message = root.TryGetProperty("message", out var message) ? message.GetString() : null + }; + } + catch + { + return new { RawLength = line.Length }; + } + } +} + +internal static class UiTrace +{ + private static readonly object Lock = new(); + private static readonly string? TracePath = CreateTracePath(); + + public static void Write(string phase, object? detail = null) + { + if (TracePath is null) + { + return; + } + + try + { + var line = JsonSerializer.Serialize(new + { + time = DateTimeOffset.UtcNow.ToString("o"), + pid = Environment.ProcessId, + process = Process.GetCurrentProcess().ProcessName, + phase, + detail + }) + Environment.NewLine; + lock (Lock) + { + File.AppendAllText(TracePath, line, Encoding.UTF8); + } + } + catch + { + } + } + + private static string? CreateTracePath() + { + try + { + var root = Environment.GetEnvironmentVariable("COVENANT_SETUP_TRACE_DIR"); + if (string.IsNullOrWhiteSpace(root)) + { + return null; + } + + Directory.CreateDirectory(root); + return Path.Combine(root, $"csharp-ui-pipe-{Environment.ProcessId}.jsonl"); + } + catch + { + return null; + } + } +} + +internal sealed class UiMessage +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("current_step")] + public int? CurrentStep { get; set; } + + [JsonPropertyName("total_steps")] + public int? TotalSteps { get; set; } + + [JsonPropertyName("buttons")] + public string? Buttons { get; set; } + + [JsonPropertyName("icon")] + public string? Icon { get; set; } +} + +internal sealed class UiResponse +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("result")] + public string? Result { get; set; } +} diff --git a/ui/Covenant.Setup.Ui/app.manifest b/ui/Covenant.Setup.Ui/app.manifest new file mode 100644 index 0000000..9376958 --- /dev/null +++ b/ui/Covenant.Setup.Ui/app.manifest @@ -0,0 +1,11 @@ + + + + + + + + + + + -- 2.47.3 From b31cca652b83e85bc3db2b78a231516ac4e176a2 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 27 Apr 2026 22:21:30 -0500 Subject: [PATCH 04/13] doc of the session --- docs/vagrant-smoke-debugging-notes.md | 438 ++++++++++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 docs/vagrant-smoke-debugging-notes.md diff --git a/docs/vagrant-smoke-debugging-notes.md b/docs/vagrant-smoke-debugging-notes.md new file mode 100644 index 0000000..cd5341e --- /dev/null +++ b/docs/vagrant-smoke-debugging-notes.md @@ -0,0 +1,438 @@ +# Vagrant Smoke Test Debugging Notes + +This document records the work done while replacing the PowerShell UI with a C# presentation layer, adding guest-side diagnostics, and stabilizing the Windows Vagrant smoke test. It is intended as a reference for future installer hangs where the VM console is not visible. + +## Scope + +The work covered these areas: + +- Removed the PowerShell-hosted UI path. +- Added a C# WinForms UI process. +- Connected Rust business logic to the C# UI over Windows named pipes. +- Added guest trace collection so hangs can be diagnosed without watching the VM console. +- Rebuilt and tested the packaged installer in the Hyper-V Vagrant guest. +- Extended the smoke test to install, verify installed state, uninstall, and verify removed state. + +## Current Architecture + +The installer is still driven by Rust. The C# process is presentation only. + +- Rust business logic lives primarily in `src/main.rs`. +- Rust C# UI IPC lives in `src/ui.rs`. +- C# WinForms UI lives in `ui/Covenant.Setup.Ui/Program.cs`. +- `build.rs` publishes the C# UI as a self-contained `win-x64` single-file executable. +- The Rust binary embeds the published C# UI executable with `include_bytes!`. +- At runtime, Rust extracts the C# UI executable to `%TEMP%\covenant-setup-ui`, starts it, and connects to a named pipe. +- The C# UI owns the pipe server and reads newline-delimited JSON messages. +- Rust sends messages such as `init`, `progress`, `log`, `finish`, `prompt`, and `close`. +- The C# UI writes prompt responses back as JSON. + +## Trace Outputs + +The guest trace directory is: + +```text +C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace +``` + +The host harness pulls this into: + +```text +dist\vagrant-self-test\trace +``` + +Important trace files: + +- `guest-events.jsonl`: host/guest harness events, scheduled task status, verification phases. +- `installer-heartbeat-.jsonl`: Rust process heartbeat and installer phases. +- `csharp-ui-pipe-.jsonl`: C# UI process and pipe receive/send events. +- `interactive-context.json`: context captured by the interactive wrapper. +- `interactive-processes.json`: relevant guest processes during wrapper diagnostics. +- `interactive-windows.json`: visible windows and process window titles. +- `interactive-application-events.json`: recent Application event log entries. +- `abort-*.json`: snapshots created by the abort collector. + +The host harness always tries to pull the trace bundle in `finally`, even when the smoke test fails. + +## Errors Encountered + +### 1. Host Sandbox and Vagrant Permissions + +Running Vagrant and Hyper-V actions from the coding sandbox required escalation. This affected commands such as: + +```powershell +.\scripts\run-windows-vm-smoke.ps1 -SkipViewer -HaltAfter +vagrant status +vagrant winrm ... +vagrant upload ... +``` + +This was expected: Vagrant controls an external VM, uses WinRM, and interacts with Hyper-V. + +### 2. Pre-main Guest Failure: Missing VCRUNTIME140.dll + +The first meaningful guest diagnostics showed a Windows system error dialog: + +```text +covenant-setup-installer.exe - System Error +The code execution cannot proceed because VCRUNTIME140.dll was not found. +``` + +Evidence: + +- `interactive-windows.json` showed a `covenant-setup-installer.exe - System Error` window. +- `system-events.json` had an `Application Popup` event for the missing DLL. +- There were no `installer-heartbeat-*.jsonl` files. +- There were no `csharp-ui-pipe-*.jsonl` files. + +Conclusion: + +The executable failed before Rust `main()` ran. The heartbeat and pipe logs were absent because neither Rust nor the C# UI started. + +Fix: + +Added `.cargo/config.toml`: + +```toml +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] +``` + +This statically links the MSVC C runtime into the Rust executable, removing the guest dependency on `VCRUNTIME140.dll`. + +### 3. Packaging Looked Successful but Produced an Unbundled EXE + +Manual PowerShell invocations of the Windows-subsystem Rust executable were misleading. A direct command such as: + +```powershell +target\release\covenant-setup.exe --json package vm\self-test\install.toml --output dist\vagrant-self-test +``` + +could return quickly with `EXIT=0` while the output file still matched the base executable size. + +Reason: + +The Rust binary is built as a Windows GUI subsystem executable. Direct invocation from PowerShell does not behave like a normal console command in all cases. + +Fixes: + +- The smoke harness invokes the packager with `Start-Process -Wait -PassThru`. +- The harness now validates that the packaged installer ends with the bundle magic marker `COVENANT_SETUP_BUNDLE_V1`. +- The embedded bundle format was changed from JSON byte arrays to an appended raw bundle with a JSON index plus raw file data. This avoids large JSON expansion of payload bytes. + +### 4. Journal Written Beside the Smoke Installer + +After the VCRUNTIME fix, the installer succeeded but the smoke verifier failed with: + +```text +Journal missing: C:\Users\vagrant\AppData\Local\CovenantSetupSelfTest\journal.json +``` + +Evidence: + +The Rust heartbeat showed: + +```json +{"phase":"install_journal_written","detail":{"journal":"C:\\Users\\vagrant\\AppData\\Local\\Temp\\covenant-setup-smoke\\journal.json"}} +``` + +Cause: + +The packaged install path was passing an explicit journal path next to the packaged installer. The expected product behavior is to infer the install root and write `journal.json` there. + +Fix: + +`run_bundled_installer` now calls: + +```rust +install(&manifest_path, None, true, ui_mode, logger) +``` + +This lets `build_install_runtime` infer the journal path from the install root. + +### 5. Scheduled Task Re-ran During Diagnostics + +The guest harness originally registered a scheduled task with a trigger one minute in the future and also started it manually. + +Failure mode: + +- The manual task run completed. +- If verification or diagnostics took long enough, the scheduled trigger fired and launched a second copy. + +Fix: + +The trigger is now set far in the future: + +```powershell +New-ScheduledTaskTrigger -Once -At (Get-Date).AddDays(1) +``` + +The harness still starts the task manually with `Start-ScheduledTask`. + +### 6. WinRM Error 1726 During Success Diagnostics + +After the installer succeeded, the host sometimes saw: + +```text +WSMAN ERROR CODE: 1726 +The WSMan provider host process did not return a proper response. +``` + +The trace showed the installer succeeded and verification reached the success diagnostics phase, but the WinRM command failed while returning. + +Fix: + +- The success path now writes `guest-result.json` immediately after verification. +- Heavy diagnostics are retained for failure paths. +- Diagnostic file writes now emit `diagnostic_file_start`, `diagnostic_file_finish`, and `diagnostic_file_error` markers so future diagnostic hangs show the exact capture that blocked. + +### 7. Install-plus-uninstall Harness Hung + +When uninstall testing was added, the install scheduled task exited with `LastTaskResult=1`. The parent loop waited until timeout because no result file was written. + +Evidence: + +- `guest-events.jsonl` showed the install scheduled task was registered and started. +- The task quickly moved to `Ready` with `LastTaskResult=1`. +- There was no `interactive_installer_start`. +- There was no Rust heartbeat. +- There was no C# pipe log. + +This meant the PowerShell wrapper failed before starting the installer. + +Reproduction: + +A harmless wrapper test failed: + +```powershell +Invoke-InteractiveInstaller.ps1 ` + -InstallerPath C:\Windows\System32\cmd.exe ` + -InstallerArguments "/c" "exit 0" +``` + +Error: + +```text +A positional parameter cannot be found that accepts argument 'exit 0'. +``` + +Cause: + +Under `powershell.exe -File`, passing multiple values to a script `[string[]]` parameter was not binding as intended. + +Fix: + +The task wrapper now passes child process arguments as base64-encoded JSON: + +```powershell +$argumentsJson = ConvertTo-Json -InputObject $Arguments -Compress +$argumentsBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($argumentsJson)) +``` + +The interactive wrapper decodes that back to a real argument array: + +```powershell +$argumentsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($InstallerArgumentsBase64)) +$decodedArguments = ConvertFrom-Json -InputObject $argumentsJson +$InstallerArguments = @() +foreach ($argument in $decodedArguments) { + $InstallerArguments += [string]$argument +} +``` + +The harmless wrapper test then produced: + +```json +"installerArgs": ["/c", "exit 0"], +"exitCode": 0 +``` + +## Uninstall Test Flow + +The smoke harness now does this inside the guest: + +1. Schedules an interactive install task. +2. Runs the packaged installer with: + + ```text + --headed --automation + ``` + +3. Verifies installed state: + + - `%LOCALAPPDATA%\CovenantSetupSelfTest\bin\covenant-setup.exe` + - `%LOCALAPPDATA%\CovenantSetupSelfTest\journal.json` + - `%LOCALAPPDATA%\CovenantSetupSelfTest\covenant-setup-uninstall.exe` + - `HKCU:\Software\CovenantSetupSelfTest` + - `Desktop\Covenant Setup Self Test.lnk` + +4. Schedules an interactive uninstall task. +5. Runs the installed uninstaller with: + + ```text + --headed --automation uninstall + ``` + +6. Waits for cleanup helper completion. +7. Verifies removed state: + + - install root removed + - payload removed + - journal removed + - installed uninstaller removed + - desktop shortcut removed + - application registry key removed + - Installed Apps uninstall registration removed + +## Automation Changes for Uninstall + +The uninstall path can spawn a cleanup helper to delete the running uninstaller executable after the main uninstall process exits. The cleanup helper previously could still show a GUI success or reboot prompt. + +Fix: + +- `uninstall` now receives the automation flag. +- `cleanup` now receives the automation flag. +- `spawn_cleanup_helper` propagates `--automation`. +- When the parent UI mode is GUI, `spawn_cleanup_helper` also passes `--headed`. +- GUI success/reboot prompts are skipped in automation mode. + +This keeps the C# progress UI visible while preventing blocking prompts during automated tests. + +## Abort Collector + +Added: + +```text +scripts/windows-vm/Abort-SmokeDiagnostics.ps1 +``` + +Purpose: + +- Write an explicit `abort_requested` event. +- Capture processes, visible windows, scheduled tasks, task info, and recent event logs. +- Stop installer, uninstaller, C# UI, and smoke scheduled tasks. +- Unregister smoke scheduled tasks. +- Zip and return the trace bundle as base64. + +This is useful when the host-side test command is interrupted and the normal `finally` block does not complete. + +## Final Verified Result + +The final smoke test command was: + +```powershell +.\scripts\run-windows-vm-smoke.ps1 -SkipViewer -HaltAfter +``` + +It passed with: + +```json +{ + "success": true, + "exitCode": 0, + "installExitCode": 0, + "uninstallExitCode": 0, + "uninstallVerified": true +} +``` + +The final trace showed both scheduled tasks completing: + +- `CovenantSetupSelfInstall-Install-...` +- `CovenantSetupSelfInstall-Uninstall-...` + +It also showed: + +- install Rust heartbeat +- install C# pipe log +- uninstall Rust heartbeat +- uninstall C# pipe log +- cleanup helper heartbeat +- `uninstall_cleanup_observed` with every checked path/key absent + +## Relevant Code Changes + +### Build and Packaging + +- `.cargo/config.toml` + - Enables static MSVC runtime linking for the Rust executable. +- `build.rs` + - Publishes the C# WinForms UI as self-contained `win-x64`. + - Sets `COVENANT_SETUP_UI_EXE` for Rust embedding. +- `src/main.rs` + - Adds trace events. + - Uses raw embedded bundle format. + - Uses C# UI IPC instead of PowerShell UI. + - Writes packaged install journal to the inferred install root. + - Propagates automation through uninstall cleanup. + +### C# UI + +- `src/ui.rs` + - Extracts embedded C# UI executable. + - Connects to a named pipe. + - Sends JSON UI messages. + - Logs Rust-side pipe events. +- `ui/Covenant.Setup.Ui/Program.cs` + - Hosts the named pipe server. + - Displays progress, logs, and prompts. + - Logs C# pipe events to `csharp-ui-pipe-.jsonl`. + +### Vagrant Harness + +- `scripts/run-windows-vm-smoke.ps1` + - Requires `dotnet`. + - Packages with `Start-Process -Wait`. + - Validates embedded bundle marker. + - Uploads guest scripts. + - Pulls trace bundle in `finally`. + - Reports install and uninstall status. +- `scripts/windows-vm/Start-InteractiveSelfInstall.ps1` + - Runs install and uninstall as separate interactive scheduled tasks. + - Verifies installed state before uninstall. + - Verifies removed state after uninstall. + - Writes detailed guest trace events. +- `scripts/windows-vm/Invoke-InteractiveInstaller.ps1` + - Starts a target executable with decoded argument list. + - Polls process state instead of relying only on `WaitForExit`. + - Writes per-operation diagnostics. +- `scripts/windows-vm/Abort-SmokeDiagnostics.ps1` + - Captures and aborts an in-progress smoke run. + +### Removed PowerShell UI + +- `scripts/windows-vm/Approve-InstallerDialogs.ps1` + - Removed because the automation path no longer clicks PowerShell UI dialogs. +- `src/win.rs` + - PowerShell/TaskDialog UI helpers were removed from the primary UI flow. + +## Troubleshooting Guide for the Next Hang + +1. Check whether the host command is still running: + + ```powershell + Get-Process | Where-Object { $_.ProcessName -match 'vagrant|ruby|covenant' } + ``` + +2. If the host-side Vagrant process is stuck and the run should be aborted, stop only the Vagrant/Ruby processes for that run. + +3. Pull guest diagnostics: + + ```powershell + vagrant upload scripts\windows-vm\Abort-SmokeDiagnostics.ps1 C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\scripts\Abort-SmokeDiagnostics.ps1 + vagrant winrm -s powershell -c "& 'C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\scripts\Abort-SmokeDiagnostics.ps1'" + ``` + +4. Inspect `dist\vagrant-self-test\trace\guest-events.jsonl`. + +5. Interpret missing logs: + + - No `interactive_installer_start`: scheduled task or PowerShell wrapper failed before launching the installer. + - `interactive_installer_start` exists, but no `installer-heartbeat-*.jsonl`: executable failed before Rust `main()`, usually loader/dependency/signing/OS error. + - Rust heartbeat exists, but no `csharp-ui-pipe-*.jsonl`: C# UI failed to start or pipe connection failed. + - Both heartbeat and pipe logs exist: inspect the last Rust phase and last C# pipe phase to find the blocked operation. + +6. Check `interactive-windows.json` for modal system dialogs. + +7. Check `abort-processes.json` and `abort-scheduled-task-info.json` for orphaned tasks or running installers. + -- 2.47.3 From d72250eb733be0ff52f4e5c956ae8fd26f3b3503 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 16:39:12 +0000 Subject: [PATCH 05/13] resolve admin-required roots via SHGetKnownFolderPath Replace the hardcoded "c:\\program files" / "c:\\windows" prefix heuristic with a PathResolver::requires_admin method that matches against runtime-resolved FOLDERID_ProgramFilesX64, FOLDERID_ProgramFilesX86, and FOLDERID_Windows. Honors the MVP "no hardcoded paths" rule, catches Program Files (x86) explicitly, and works on non-C: Windows installs. Match logic uses path-component boundaries so "C:\\Program Files Custom" no longer false-positives against "C:\\Program Files". Adds unit tests for the new method via a #[cfg(test)] constructor that lets us seed roots without invoking Win32. --- src/main.rs | 15 +++---- src/win.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 11bc104..53b113e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1699,17 +1699,17 @@ fn manifest_requires_admin( resolver: &win::PathResolver, ) -> Result { for directory in &manifest.directories { - if path_requires_admin(&resolver.resolve(&directory.path)) { + if resolver.requires_admin(&resolver.resolve(&directory.path)) { return Ok(true); } } for file in &manifest.files { - if path_requires_admin(&resolver.resolve(&file.destination)) { + if resolver.requires_admin(&resolver.resolve(&file.destination)) { return Ok(true); } } for shortcut in &manifest.shortcuts { - if path_requires_admin(&resolver.resolve(&shortcut.path)) { + if resolver.requires_admin(&resolver.resolve(&shortcut.path)) { return Ok(true); } } @@ -1733,7 +1733,7 @@ fn journal_requires_admin( destination: path, .. } | JournalAction::CreateShortcut { path } => { - if path_requires_admin(path) { + if resolver.requires_admin(path) { return Ok(true); } } @@ -1750,7 +1750,7 @@ fn journal_requires_admin( } } for path in &journal.purge.paths { - if path_requires_admin(&resolver.resolve(path)) { + if resolver.requires_admin(&resolver.resolve(path)) { return Ok(true); } } @@ -1841,11 +1841,6 @@ fn normalize_path_for_compare(path: &Path) -> String { .to_ascii_lowercase() } -fn path_requires_admin(path: &Path) -> bool { - let path = path.to_string_lossy().to_ascii_lowercase(); - path.starts_with("c:\\program files") || path.starts_with("c:\\windows") -} - fn absolutize(base: Option<&Path>, value: &str) -> PathBuf { let candidate = PathBuf::from(value); if candidate.is_absolute() { diff --git a/src/win.rs b/src/win.rs index e89e375..4844a81 100644 --- a/src/win.rs +++ b/src/win.rs @@ -30,16 +30,20 @@ use windows::Win32::System::RestartManager::{ }; use windows::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, OpenProcessToken}; use windows::Win32::UI::Shell::{ - FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64, IShellLinkW, - KNOWN_FOLDER_FLAG, SHGetKnownFolderPath, ShellExecuteW, ShellLink, + FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64, FOLDERID_ProgramFilesX86, + FOLDERID_Windows, IShellLinkW, KNOWN_FOLDER_FLAG, SHGetKnownFolderPath, ShellExecuteW, + ShellLink, }; use windows::Win32::UI::WindowsAndMessaging::SW_SHOW; use windows::core::{Interface, PCWSTR, PWSTR, w}; pub struct PathResolver { pub program_files_x64: PathBuf, + pub program_files_x86: PathBuf, + pub windows_dir: PathBuf, pub local_app_data: PathBuf, pub desktop: PathBuf, + admin_roots: Vec, } pub fn is_parent_powershell(logger: &Logger) -> Result { @@ -113,10 +117,17 @@ pub fn is_parent_powershell(logger: &Logger) -> Result { impl PathResolver { pub fn new(logger: &Logger) -> Result { + let program_files_x64 = known_folder(&FOLDERID_ProgramFilesX64, logger)?; + let program_files_x86 = known_folder(&FOLDERID_ProgramFilesX86, logger)?; + let windows_dir = known_folder(&FOLDERID_Windows, logger)?; + let admin_roots = build_admin_roots(&[&program_files_x64, &program_files_x86, &windows_dir]); Ok(Self { - program_files_x64: known_folder(&FOLDERID_ProgramFilesX64, logger)?, + program_files_x64, + program_files_x86, + windows_dir, local_app_data: known_folder(&FOLDERID_LocalAppData, logger)?, desktop: known_folder(&FOLDERID_Desktop, logger)?, + admin_roots, }) } @@ -131,6 +142,45 @@ impl PathResolver { .replace("{Desktop}", &self.desktop.to_string_lossy()), ) } + + pub fn requires_admin(&self, path: &Path) -> bool { + let candidate = normalize_for_admin_match(path); + self.admin_roots.iter().any(|root| { + candidate == *root || candidate.starts_with(&format!("{root}\\")) + }) + } + + #[cfg(test)] + pub(crate) fn with_roots_for_test(roots: Vec) -> Self { + let admin_roots = build_admin_roots(&roots.iter().collect::>()); + Self { + program_files_x64: PathBuf::new(), + program_files_x86: PathBuf::new(), + windows_dir: PathBuf::new(), + local_app_data: PathBuf::new(), + desktop: PathBuf::new(), + admin_roots, + } + } +} + +fn build_admin_roots(roots: &[&PathBuf]) -> Vec { + roots + .iter() + .map(|p| { + let lower = p.to_string_lossy().replace('/', "\\").to_ascii_lowercase(); + lower.trim_end_matches('\\').to_string() + }) + .filter(|root| !root.is_empty()) + .collect() +} + +fn normalize_for_admin_match(path: &Path) -> String { + let lower = path + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase(); + lower.trim_end_matches('\\').to_string() } pub fn is_elevated(logger: &Logger) -> Result { @@ -574,3 +624,74 @@ impl Utf16Arg { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn resolver() -> PathResolver { + PathResolver::with_roots_for_test(vec![ + PathBuf::from("C:\\Program Files"), + PathBuf::from("C:\\Program Files (x86)"), + PathBuf::from("D:\\Windows"), + ]) + } + + #[test] + fn requires_admin_matches_subpaths() { + let r = resolver(); + assert!(r.requires_admin(Path::new("C:\\Program Files\\App\\bin"))); + assert!(r.requires_admin(Path::new("C:\\Program Files (x86)\\Vendor\\app.exe"))); + assert!(r.requires_admin(Path::new("D:\\Windows\\System32\\drivers"))); + } + + #[test] + fn requires_admin_matches_exact_root() { + let r = resolver(); + assert!(r.requires_admin(Path::new("C:\\Program Files"))); + assert!(r.requires_admin(Path::new("C:\\Program Files\\"))); + } + + #[test] + fn requires_admin_rejects_sibling_prefix() { + let r = resolver(); + assert!(!r.requires_admin(Path::new("C:\\Program Files Custom\\App"))); + assert!(!r.requires_admin(Path::new("C:\\Program Files2\\App"))); + assert!(!r.requires_admin(Path::new("D:\\WindowsApps\\thing"))); + } + + #[test] + fn requires_admin_rejects_user_paths() { + let r = resolver(); + assert!(!r.requires_admin(Path::new("C:\\Users\\alice\\AppData\\Local\\App"))); + assert!(!r.requires_admin(Path::new("D:\\data\\App"))); + assert!(!r.requires_admin(Path::new("E:\\"))); + } + + #[test] + fn requires_admin_is_case_insensitive() { + let r = resolver(); + assert!(r.requires_admin(Path::new("c:\\PROGRAM FILES\\App"))); + assert!(r.requires_admin(Path::new("D:\\windows\\System32"))); + } + + #[test] + fn requires_admin_normalizes_forward_slashes() { + let r = resolver(); + assert!(r.requires_admin(Path::new("C:/Program Files/App/bin"))); + assert!(r.requires_admin(Path::new("D:/Windows/System32"))); + } + + #[test] + fn requires_admin_handles_non_windows_roots() { + let r = PathResolver::with_roots_for_test(vec![PathBuf::from("E:\\Apps\\Program Files")]); + assert!(r.requires_admin(Path::new("E:\\Apps\\Program Files\\App"))); + assert!(!r.requires_admin(Path::new("C:\\Program Files\\App"))); + } + + #[test] + fn requires_admin_with_empty_roots_returns_false() { + let r = PathResolver::with_roots_for_test(vec![]); + assert!(!r.requires_admin(Path::new("C:\\Program Files\\App"))); + } +} -- 2.47.3 From 3adb61b7517bf5cf858aa075e02cc962d91ecb8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 16:48:34 +0000 Subject: [PATCH 06/13] drop unused PathResolver fields program_files_x86 and windows_dir were stored but never read after construction; only their string forms via admin_roots are used. Compute them as locals in PathResolver::new instead, silencing the dead_code warning without expanding the public surface. --- src/win.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/win.rs b/src/win.rs index 4844a81..38aa673 100644 --- a/src/win.rs +++ b/src/win.rs @@ -39,8 +39,6 @@ use windows::core::{Interface, PCWSTR, PWSTR, w}; 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, @@ -120,11 +118,10 @@ impl PathResolver { let program_files_x64 = known_folder(&FOLDERID_ProgramFilesX64, logger)?; let program_files_x86 = known_folder(&FOLDERID_ProgramFilesX86, logger)?; let windows_dir = known_folder(&FOLDERID_Windows, logger)?; - let admin_roots = build_admin_roots(&[&program_files_x64, &program_files_x86, &windows_dir]); + let admin_roots = + build_admin_roots(&[&program_files_x64, &program_files_x86, &windows_dir]); 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, @@ -155,8 +152,6 @@ impl PathResolver { let admin_roots = build_admin_roots(&roots.iter().collect::>()); Self { program_files_x64: PathBuf::new(), - program_files_x86: PathBuf::new(), - windows_dir: PathBuf::new(), local_app_data: PathBuf::new(), desktop: PathBuf::new(), admin_roots, -- 2.47.3 From 65c415a59a8e20c1538c453592a281800f43bca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 17:02:38 +0000 Subject: [PATCH 07/13] document optional Linux cross-build path Wine runs the test binary cleanly when targeting x86_64-pc-windows-gnu, so wire that target's runner to wine in .cargo/config.toml. The default windows-msvc build is unaffected. README gains a 'Linux Cross-Build (Optional)' section with the dotnet 10 / mingw-w64 / wine prerequisites and the EnableWindowsTargeting=true recipe needed to cross-build the net8.0-windows C# UI from Linux. --- .cargo/config.toml | 6 ++++++ README.md | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index ac2b23f..2537535 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,8 @@ [target.x86_64-pc-windows-msvc] rustflags = ["-C", "target-feature=+crt-static"] + +# Linux cross-build (optional). Only takes effect when you explicitly pass +# `--target x86_64-pc-windows-gnu`. Windows builds (the default) are unaffected. +# See README "Linux cross-build" for prerequisites. +[target.x86_64-pc-windows-gnu] +runner = "wine" diff --git a/README.md b/README.md index 7722bd1..f8faaec 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,29 @@ cargo build --release Interactive GUI/TUI flows now have a Windows VM smoke harness for packaged installer behavior, while broader automated coverage is still limited. +## Linux Cross-Build (Optional) + +Day-to-day work happens on Windows. This section documents an opt-in path for type-checking and running unit tests from a Linux host (useful for CI containers or quick iteration without a VM). + +Prerequisites (Ubuntu 24.04 names): + +- `dotnet-sdk-10.0` — the C# UI build script (`build.rs`) invokes `dotnet publish`. +- `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: + +```bash +EnableWindowsTargeting=true cargo check --target x86_64-pc-windows-gnu +EnableWindowsTargeting=true cargo test --target x86_64-pc-windows-gnu +``` + +Caveats: + +- `cargo run` (and the `package`/`install`/`uninstall` flows generally) require real Win32 — Wine handles unit-test execution but is not a substitute for the Windows VM smoke harness. +- The `windows-msvc` target (the default on Windows) is unaffected by this section. + ## Windows VM Smoke Test A Windows Hyper-V Vagrant VM now lives in [`Vagrantfile`](C:\Users\jasonross\workspace\covenant-setup\Vagrantfile), and the host harness in [`scripts/run-windows-vm-smoke.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\run-windows-vm-smoke.ps1) packages `covenant-setup`, boots the VM, opens Hyper-V's console viewer, and runs the packaged installer inside the guest's interactive desktop session. -- 2.47.3 From 15ba3995a5e860358ce78c26d03f44eb336af5b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 21:48:02 +0000 Subject: [PATCH 08/13] quote args in relaunch_as_admin per CommandLineToArgvW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation joined std::env::args() with single spaces, which silently broke any argument containing whitespace, double quotes, or trailing backslashes — most importantly manifest paths under 'C:\Program Files' or 'C:\Users\'. After elevation the relaunched process saw a different argv than the original. quote_command_line_arg follows the standard MSVCRT / CommandLineToArgvW encoding: quote on space/tab/quote/empty, escape embedded quotes with a preceding backslash, and double any run of backslashes that would otherwise be consumed by a following quote (including the closing one). Tests cover the canonical edge cases plus a round-trip that re-parses each encoded form with a reference argv parser to confirm fidelity. --- src/win.rs | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 1 deletion(-) diff --git a/src/win.rs b/src/win.rs index 38aa673..0ff4b94 100644 --- a/src/win.rs +++ b/src/win.rs @@ -178,6 +178,51 @@ fn normalize_for_admin_match(path: &Path) -> String { lower.trim_end_matches('\\').to_string() } +// Encodes a single argument for a Windows command line that will be parsed by +// CommandLineToArgvW (which is what ShellExecuteW's lpParameters feeds into, +// and what every standard Win32 process startup uses to populate argv). +// +// Rules: quote if empty or contains space/tab/quote; inside quotes, escape `"` +// as `\"` and double any run of backslashes that immediately precedes a quote +// or the closing quote. +fn quote_command_line_arg(arg: &str) -> String { + let needs_quoting = arg.is_empty() + || arg + .chars() + .any(|c| c == ' ' || c == '\t' || c == '"'); + if !needs_quoting { + return arg.to_string(); + } + + let mut out = String::with_capacity(arg.len() + 2); + out.push('"'); + let mut backslashes = 0usize; + for c in arg.chars() { + match c { + '\\' => backslashes += 1, + '"' => { + for _ in 0..(backslashes * 2 + 1) { + out.push('\\'); + } + out.push('"'); + backslashes = 0; + } + _ => { + for _ in 0..backslashes { + out.push('\\'); + } + out.push(c); + backslashes = 0; + } + } + } + for _ in 0..(backslashes * 2) { + out.push('\\'); + } + out.push('"'); + out +} + pub fn is_elevated(logger: &Logger) -> Result { let mut token = HANDLE::default(); logger.unsafe_enter("OpenProcessToken", json!({})); @@ -206,7 +251,11 @@ pub fn is_elevated(logger: &Logger) -> Result { pub fn relaunch_as_admin(logger: &Logger) -> Result<(), AppError> { let exe = std::env::current_exe()?; - let params = std::env::args().skip(1).collect::>().join(" "); + let params = std::env::args() + .skip(1) + .map(|arg| quote_command_line_arg(&arg)) + .collect::>() + .join(" "); logger.unsafe_enter( "ShellExecuteW", json!({"verb":"runas","exe":exe,"params":params}), @@ -689,4 +738,130 @@ mod tests { let r = PathResolver::with_roots_for_test(vec![]); assert!(!r.requires_admin(Path::new("C:\\Program Files\\App"))); } + + #[test] + fn quote_passthrough_when_no_special_chars() { + assert_eq!(quote_command_line_arg("install"), "install"); + assert_eq!(quote_command_line_arg("C:\\Apps\\foo.exe"), "C:\\Apps\\foo.exe"); + assert_eq!(quote_command_line_arg("--json"), "--json"); + } + + #[test] + fn quote_wraps_when_contains_space_or_tab() { + assert_eq!(quote_command_line_arg("hello world"), "\"hello world\""); + assert_eq!(quote_command_line_arg("a\tb"), "\"a\tb\""); + assert_eq!( + quote_command_line_arg("C:\\Program Files\\App\\install.toml"), + "\"C:\\Program Files\\App\\install.toml\"" + ); + } + + #[test] + fn quote_escapes_embedded_double_quotes() { + assert_eq!(quote_command_line_arg("a\"b"), "\"a\\\"b\""); + assert_eq!(quote_command_line_arg("\""), "\"\\\"\""); + } + + #[test] + fn quote_doubles_trailing_backslashes_before_closing_quote() { + // "C:\Path\" must serialize as "\"C:\\Path\\\\\"" so the parser sees + // the backslashes as literals and the final quote as the terminator. + assert_eq!( + quote_command_line_arg("C:\\Path with space\\"), + "\"C:\\Path with space\\\\\"" + ); + } + + #[test] + fn quote_doubles_backslashes_only_when_followed_by_quote() { + // \\ inside an unquoted-needing arg stays \\ when not before a quote. + assert_eq!( + quote_command_line_arg("a\\\\b c"), + "\"a\\\\b c\"" + ); + // \\ immediately before a literal quote becomes \\\\\". + assert_eq!( + quote_command_line_arg("a\\\\\"b"), + "\"a\\\\\\\\\\\"b\"" + ); + } + + #[test] + fn quote_emits_explicit_empty_argument() { + assert_eq!(quote_command_line_arg(""), "\"\""); + } + + #[test] + fn quote_round_trips_through_argv_rules() { + // Sanity-check that re-parsing the quoted form per the + // CommandLineToArgvW spec recovers the original argument. + for input in [ + "simple", + "with space", + "a\"b", + "C:\\Program Files\\app\\bin", + "C:\\Path with space\\", + "trailing\\\\", + "embedded\\\"quote", + "", + ] { + let quoted = quote_command_line_arg(input); + let parsed = parse_argv_for_test("ed); + assert_eq!(parsed, vec![input.to_string()], "input was {input:?}"); + } + } + + // Reference parser following the CommandLineToArgvW algorithm, used only + // to validate the encoder above. + fn parse_argv_for_test(line: &str) -> Vec { + let mut args = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut backslashes = 0usize; + let mut started = false; + + let flush_backslashes = |current: &mut String, n: usize| { + for _ in 0..n { + current.push('\\'); + } + }; + + for c in line.chars() { + match c { + '\\' => { + backslashes += 1; + started = true; + } + '"' => { + flush_backslashes(&mut current, backslashes / 2); + if backslashes % 2 == 1 { + current.push('"'); + } else { + in_quotes = !in_quotes; + } + backslashes = 0; + started = true; + } + ' ' | '\t' if !in_quotes => { + flush_backslashes(&mut current, backslashes); + backslashes = 0; + if started { + args.push(std::mem::take(&mut current)); + started = false; + } + } + _ => { + flush_backslashes(&mut current, backslashes); + backslashes = 0; + current.push(c); + started = true; + } + } + } + flush_backslashes(&mut current, backslashes); + if started { + args.push(current); + } + args + } } -- 2.47.3 From c87a805534509805dce4d8629fb9414a68e8c2dd Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 28 Apr 2026 20:19:27 -0500 Subject: [PATCH 09/13] core mvp complete --- .gitignore | 2 + CLAUDE.md | 21 +- Cargo.toml | 1 - README.md | 24 +- build.rs | 25 +- docs/implementation-notes.md | 296 ++ docs/integration-tests-architecture.md | 321 +++ scripts/run-windows-vm-coverage.ps1 | 210 ++ scripts/windows-vm/coverage/bundled-exec.ps1 | 42 + scripts/windows-vm/coverage/hklm-registry.ps1 | 40 + scripts/windows-vm/coverage/reboot.ps1 | 46 + scripts/windows-vm/coverage/self-test.ps1 | 31 + scripts/windows-vm/coverage/uac.ps1 | 38 + src/main.rs | 2493 +++++++++++++++-- src/sys.rs | 175 ++ src/ui.rs | 246 +- src/win.rs | 275 +- .../Covenant.Setup.Ui.Tests.csproj | 24 + .../InstallerUiFormHelperTests.cs | 142 + ui/Covenant.Setup.Ui.Tests/ProgramTests.cs | 47 + .../UiMessageJsonTests.cs | 90 + ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj | 5 +- ui/Covenant.Setup.Ui/Program.cs | 149 +- 23 files changed, 4350 insertions(+), 393 deletions(-) create mode 100644 docs/implementation-notes.md create mode 100644 docs/integration-tests-architecture.md create mode 100644 scripts/run-windows-vm-coverage.ps1 create mode 100644 scripts/windows-vm/coverage/bundled-exec.ps1 create mode 100644 scripts/windows-vm/coverage/hklm-registry.ps1 create mode 100644 scripts/windows-vm/coverage/reboot.ps1 create mode 100644 scripts/windows-vm/coverage/self-test.ps1 create mode 100644 scripts/windows-vm/coverage/uac.ps1 create mode 100644 src/sys.rs create mode 100644 ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj create mode 100644 ui/Covenant.Setup.Ui.Tests/InstallerUiFormHelperTests.cs create mode 100644 ui/Covenant.Setup.Ui.Tests/ProgramTests.cs create mode 100644 ui/Covenant.Setup.Ui.Tests/UiMessageJsonTests.cs 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; } -- 2.47.3 From 0a2195bb35b82dedccd37cde73cdb509a1116a7d Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 28 Apr 2026 20:29:53 -0500 Subject: [PATCH 10/13] status check workflow added --- .github/workflows/ci.yml | 122 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66cb1d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1" + DOTNET_NOLOGO: "1" + +jobs: + build: + name: Build (Rust + C# UI) + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1" + + - name: Cache cargo registry and target + uses: Swatinem/rust-cache@v2 + + - name: Install .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.x + + - name: Restore C# UI projects + run: dotnet restore ui\Covenant.Setup.Ui\Covenant.Setup.Ui.csproj + + - name: Cargo build (release) + run: cargo build --release --locked + + rust-tests: + name: Rust unit tests + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1" + + - name: Cache cargo registry and target + uses: Swatinem/rust-cache@v2 + + - name: Install .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.x + + - name: Cargo test (including ignored / risky tests) + run: cargo test --locked -- --include-ignored + + ui-tests: + name: C# UI unit tests + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 10.x + + - name: Restore UI test project + run: dotnet restore ui\Covenant.Setup.Ui.Tests\Covenant.Setup.Ui.Tests.csproj + + - name: Run UI tests + run: dotnet test ui\Covenant.Setup.Ui.Tests\Covenant.Setup.Ui.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=ui-tests.trx" + + - name: Upload UI test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: ui-test-results + path: ui\Covenant.Setup.Ui.Tests\TestResults\*.trx + if-no-files-found: ignore + + quality-gate: + name: Quality gate + runs-on: windows-latest + needs: [build, rust-tests, ui-tests] + if: always() + steps: + - name: Verify all required jobs succeeded + shell: pwsh + run: | + $results = @{ + build = '${{ needs.build.result }}' + rust_tests = '${{ needs.rust-tests.result }}' + ui_tests = '${{ needs.ui-tests.result }}' + } + $failed = $false + foreach ($entry in $results.GetEnumerator()) { + Write-Host ("{0,-12} : {1}" -f $entry.Key, $entry.Value) + if ($entry.Value -ne 'success') { $failed = $true } + } + if ($failed) { + Write-Error 'One or more required jobs did not succeed.' + exit 1 + } + Write-Host 'All required jobs succeeded.' -- 2.47.3 From d64fa5bc2975dda9ccbb120f1d20b4bcc2ebd62c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 28 Apr 2026 20:48:28 -0500 Subject: [PATCH 11/13] status check workflow update --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66cb1d2..7060c8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,19 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master - with: - toolchain: "1" + - name: Install Visual Studio Build Tools (VC + Win11 SDK) + shell: pwsh + run: | + winget install --id Microsoft.VisualStudio.2022.BuildTools ` + --override "--add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait" ` + --accept-package-agreements --accept-source-agreements --disable-interactivity + + - name: Install Rust via Chocolatey + shell: pwsh + run: | + choco install rust -y --no-progress + $rustBin = Join-Path $env:ProgramData 'chocolatey\lib\rust\tools\bin' + if (Test-Path $rustBin) { Add-Content -Path $env:GITHUB_PATH -Value $rustBin } - name: Cache cargo registry and target uses: Swatinem/rust-cache@v2 @@ -52,10 +61,19 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master - with: - toolchain: "1" + - name: Install Visual Studio Build Tools (VC + Win11 SDK) + shell: pwsh + run: | + winget install --id Microsoft.VisualStudio.2022.BuildTools ` + --override "--add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait" ` + --accept-package-agreements --accept-source-agreements --disable-interactivity + + - name: Install Rust via Chocolatey + shell: pwsh + run: | + choco install rust -y --no-progress + $rustBin = Join-Path $env:ProgramData 'chocolatey\lib\rust\tools\bin' + if (Test-Path $rustBin) { Add-Content -Path $env:GITHUB_PATH -Value $rustBin } - name: Cache cargo registry and target uses: Swatinem/rust-cache@v2 -- 2.47.3 From 1d832bc5180bf6f2b8f9b447e213e1e9664511f2 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 28 Apr 2026 20:58:58 -0500 Subject: [PATCH 12/13] fix Rust toolchain to run against proper C binary --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7060c8c..db8e09c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,8 @@ jobs: - name: Install Rust via Chocolatey shell: pwsh run: | - choco install rust -y --no-progress - $rustBin = Join-Path $env:ProgramData 'chocolatey\lib\rust\tools\bin' + choco install rust-ms -y --no-progress + $rustBin = Join-Path $env:ProgramData 'chocolatey\lib\rust-ms\tools\rust-ms\bin' if (Test-Path $rustBin) { Add-Content -Path $env:GITHUB_PATH -Value $rustBin } - name: Cache cargo registry and target @@ -71,8 +71,8 @@ jobs: - name: Install Rust via Chocolatey shell: pwsh run: | - choco install rust -y --no-progress - $rustBin = Join-Path $env:ProgramData 'chocolatey\lib\rust\tools\bin' + choco install rust-ms -y --no-progress + $rustBin = Join-Path $env:ProgramData 'chocolatey\lib\rust-ms\tools\rust-ms\bin' if (Test-Path $rustBin) { Add-Content -Path $env:GITHUB_PATH -Value $rustBin } - name: Cache cargo registry and target -- 2.47.3 From 74e37e88999b4f02e8e5f591bad8d7c66f4b0967 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:07:20 +0000 Subject: [PATCH 13/13] fix: apply review comment fixes for README, project_mvp, Program.cs, win.rs Agent-Logs-Url: https://github.com/JMR-dev/covenant-setup/sessions/351376c1-0b93-4176-9cab-35d4cc72957a Co-authored-by: JMR-dev <51939451+JMR-dev@users.noreply.github.com> --- README.md | 8 ++++---- project_mvp.md | 2 +- src/win.rs | 9 +++++---- ui/Covenant.Setup.Ui.Tests/ProgramTests.cs | 9 ++++++++- ui/Covenant.Setup.Ui/Program.cs | 8 +++++++- 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 252602c..9079ac2 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ Windows has a mess when it comes to managing program lifecycles. Developers can This packager aims to take a different approach by -- Observing all the places a program installs to during installation and during any post-install scripts/operations and then writing a journal.json to the same directory the application installs to. This file is referenced during uninstall to return the machine back to the state it was before the install with any files and registry entries associated with that program. +- Recording the install actions it applies (files, directories, registry entries, shortcuts, and script execution) into a `journal.json` written alongside the installed application. This journal is then used during uninstall to reverse those recorded actions and clean up associated state. - Take a "leave the campground better than you found it" approach - this Eagle Scout practices Leave No Trace. -- Taking a "trust but verify model" to program installs and uninstalls, observing program behavior during install and uninstall in order to respect the user. -- Using the `journal.json` as a manifest of everything the program did during the install and post install process. +- Taking a "trust but verify model" to program installs and uninstalls by journaling engine-applied mutations and logging script execution in order to respect the user. +- Using the `journal.json` as a manifest of the actions the installer performed during install and post-install processing. Its current shape is: @@ -29,7 +29,7 @@ Its current shape is: - Creates an installed uninstaller executable in the app root - Uses a C# WinForms presentation process for GUI progress and prompts - Sends GUI state over named-pipe IPC from the Rust engine to the C# UI -- Uses Win32 APIs through the `windows` crate with unsafe isolated in [`src/win.rs`](C:\Users\jasonross\workspace\covenant-setup\src\win.rs) +- Uses Win32 APIs through the `windows` crate with unsafe isolated in [`src/win.rs`](src/win.rs) - Logs every unsafe boundary transition ## Packaging Model diff --git a/project_mvp.md b/project_mvp.md index 21c5ca5..d656272 100644 --- a/project_mvp.md +++ b/project_mvp.md @@ -8,7 +8,7 @@ ## MVP Requirements & Feature List ### 1. The Rust CLI Interface & IPC Readiness -* **CLI Framework:** Utilize `clap` for robust argument parsing with standard subcommands (e.g., `glassbox install manifest.toml`, `glassbox uninstall journal.json`). +* **CLI Framework:** Utilize `clap` for robust argument parsing with standard subcommands (e.g., `covenant-setup install manifest.toml`, `covenant-setup uninstall journal.json`). * **Structured Output Protocol:** The engine must accept a `--json` flag. When active, all standard text logs, progress percentages, and error stack traces must be suppressed and replaced with single-line serialized JSON objects emitted to `stdout`. * **UAC Handling:** The CLI must detect if it has administrative privileges via token inspection. If elevation is required for target paths, it must gracefully exit with a specific error code or auto-relaunch itself using the `runas` verb. diff --git a/src/win.rs b/src/win.rs index b26cb26..2d9569e 100644 --- a/src/win.rs +++ b/src/win.rs @@ -157,17 +157,18 @@ pub fn is_elevated(logger: &Logger) -> Result { let mut elevation = TOKEN_ELEVATION::default(); let mut returned = 0u32; logger.unsafe_enter("GetTokenInformation", json!({"class":"TokenElevation"})); - unsafe { + let info_result = unsafe { GetTokenInformation( token, TokenElevation, Some((&mut elevation as *mut TOKEN_ELEVATION).cast::()), std::mem::size_of::() as u32, &mut returned, - )? + ) }; logger.unsafe_exit("GetTokenInformation", json!({"returned": returned})); close_handle(token, logger)?; + info_result?; if returned < std::mem::size_of::() as u32 { return Err(AppError::Message("Short TOKEN_ELEVATION payload".into())); } @@ -493,11 +494,11 @@ fn known_folder(id: &windows::core::GUID, logger: &Logger) -> Result Result { diff --git a/ui/Covenant.Setup.Ui.Tests/ProgramTests.cs b/ui/Covenant.Setup.Ui.Tests/ProgramTests.cs index 45213ac..e1da1d2 100644 --- a/ui/Covenant.Setup.Ui.Tests/ProgramTests.cs +++ b/ui/Covenant.Setup.Ui.Tests/ProgramTests.cs @@ -8,9 +8,16 @@ public class ProgramTests { [Fact] public void ReadPipeName_returns_value_following_pipe_flag() + { + var name = Program.ReadPipeName(new[] { "--pipe", "foo" }); + Assert.Equal("foo", name); + } + + [Fact] + public void ReadPipeName_strips_full_pipe_path_prefix() { var name = Program.ReadPipeName(new[] { "--pipe", @"\\.\pipe\foo" }); - Assert.Equal(@"\\.\pipe\foo", name); + Assert.Equal("foo", name); } [Fact] diff --git a/ui/Covenant.Setup.Ui/Program.cs b/ui/Covenant.Setup.Ui/Program.cs index d70e3f6..cfcfdca 100644 --- a/ui/Covenant.Setup.Ui/Program.cs +++ b/ui/Covenant.Setup.Ui/Program.cs @@ -31,7 +31,13 @@ internal static class Program { if (string.Equals(args[i], "--pipe", StringComparison.OrdinalIgnoreCase)) { - return args[i + 1]; + var value = args[i + 1]; + const string pipePrefix = @"\\.\pipe\"; + if (value.StartsWith(pipePrefix, StringComparison.OrdinalIgnoreCase)) + { + value = value[pipePrefix.Length..]; + } + return value; } } -- 2.47.3