From a5d6c8aa99f798eb4efe5b65951656ed7679fae1 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 27 Mar 2026 21:18:48 -0500 Subject: [PATCH] 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::(), + ) + } + } +}