Feat MVP #1

Merged
JMR-dev merged 13 commits from feat-mvp into main 2026-04-29 02:16:38 +00:00
39 changed files with 10189 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
# Linux cross-build (optional). Only takes effect when you explicitly pass
# `--target x86_64-pc-windows-gnu`. Windows builds (the default) are unaffected.
# See README "Linux cross-build" for prerequisites.
[target.x86_64-pc-windows-gnu]
runner = "wine"
+140
View File
@@ -0,0 +1,140 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
DOTNET_NOLOGO: "1"
jobs:
build:
name: Build (Rust + C# UI)
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Visual Studio Build Tools (VC + Win11 SDK)
shell: pwsh
run: |
winget install --id Microsoft.VisualStudio.2022.BuildTools `
--override "--add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait" `
--accept-package-agreements --accept-source-agreements --disable-interactivity
- name: Install Rust via Chocolatey
shell: pwsh
run: |
choco install rust-ms -y --no-progress
$rustBin = Join-Path $env:ProgramData 'chocolatey\lib\rust-ms\tools\rust-ms\bin'
if (Test-Path $rustBin) { Add-Content -Path $env:GITHUB_PATH -Value $rustBin }
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
- name: Install .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
10.x
- name: Restore C# UI projects
run: dotnet restore ui\Covenant.Setup.Ui\Covenant.Setup.Ui.csproj
- name: Cargo build (release)
run: cargo build --release --locked
rust-tests:
name: Rust unit tests
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Visual Studio Build Tools (VC + Win11 SDK)
shell: pwsh
run: |
winget install --id Microsoft.VisualStudio.2022.BuildTools `
--override "--add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --passive --wait" `
--accept-package-agreements --accept-source-agreements --disable-interactivity
- name: Install Rust via Chocolatey
shell: pwsh
run: |
choco install rust-ms -y --no-progress
$rustBin = Join-Path $env:ProgramData 'chocolatey\lib\rust-ms\tools\rust-ms\bin'
if (Test-Path $rustBin) { Add-Content -Path $env:GITHUB_PATH -Value $rustBin }
- name: Cache cargo registry and target
uses: Swatinem/rust-cache@v2
- name: Install .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
10.x
- name: Cargo test (including ignored / risky tests)
run: cargo test --locked -- --include-ignored
ui-tests:
name: C# UI unit tests
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
10.x
- name: Restore UI test project
run: dotnet restore ui\Covenant.Setup.Ui.Tests\Covenant.Setup.Ui.Tests.csproj
- name: Run UI tests
run: dotnet test ui\Covenant.Setup.Ui.Tests\Covenant.Setup.Ui.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=ui-tests.trx"
- name: Upload UI test results
if: always()
uses: actions/upload-artifact@v4
with:
name: ui-test-results
path: ui\Covenant.Setup.Ui.Tests\TestResults\*.trx
if-no-files-found: ignore
quality-gate:
name: Quality gate
runs-on: windows-latest
needs: [build, rust-tests, ui-tests]
if: always()
steps:
- name: Verify all required jobs succeeded
shell: pwsh
run: |
$results = @{
build = '${{ needs.build.result }}'
rust_tests = '${{ needs.rust-tests.result }}'
ui_tests = '${{ needs.ui-tests.result }}'
}
$failed = $false
foreach ($entry in $results.GetEnumerator()) {
Write-Host ("{0,-12} : {1}" -f $entry.Key, $entry.Value)
if ($entry.Value -ne 'success') { $failed = $true }
}
if ($failed) {
Write-Error 'One or more required jobs did not succeed.'
exit 1
}
Write-Host 'All required jobs succeeded.'
+9 -1
View File
@@ -1 +1,9 @@
.target/
# Added by cargo
/dist*
/target
/.vagrant/
/vm/self-test/payload/
**/bin/
**/obj/
vm/
+75
View File
@@ -0,0 +1,75 @@
# 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 Rust automated test suite exists yet beyond the in-tree `#[cfg(test)]`
unit tests (`cargo test`, 96 tests). C# UI unit tests live in a sibling
project and run via:
```bash
dotnet test ui/Covenant.Setup.Ui.Tests/Covenant.Setup.Ui.Tests.csproj
```
Real Win32/UAC/registry boundaries are validated by the Vagrant harness
(`scripts/run-windows-vm-coverage.ps1`) — see
`docs/integration-tests-architecture.md`. Manual interactive testing uses
the example manifest (`examples/install.toml`).
## Architecture
**Three source files:**
- `src/main.rs` — CLI (clap derive), manifest parsing, install/uninstall/package logic, journaling, UI (TUI/GUI/JSON), elevation handling
- `src/sys.rs` — `Sys` trait abstracting every external boundary (Win32 elevation/registry/MoveFileEx fallback, reboot, cleanup-helper spawn, embedded-bundle probe, GUI prompts, optional `ProgressSink` injection). `WinSys` is the production implementation that delegates to `crate::win::*`, `crate::ui::*`, and the local helpers; `MockSys` (in `mod tests`) records every call for unit tests.
- `src/win.rs` — All Win32 FFI isolated here. Every `unsafe` block is bracketed with `logger.unsafe_enter()`/`unsafe_exit()` calls. Contains `PathResolver` for known-folder token resolution, file/directory/registry/shortcut operations, Restart Manager queries, and elevation checks.
**Three operational modes (CLI subcommands):**
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`
- All external boundaries (`win::*`, `ui::*` prompts, reboot/cleanup-helper spawning, embedded-bundle probe) flow through the `Sys` trait in `src/sys.rs` so orchestration code can be unit-tested with `MockSys`
- UTF-16 conversion uses the `Utf16Arg` wrapper type
- Registry always uses `KEY_WOW64_64KEY` for explicit 64-bit access
- Path tokens (`{ProgramFilesX64}`, etc.) are resolved at runtime, never hardcoded
- Subprocess calls use `CREATE_NO_WINDOW` flag
- Rust edition 2024
## VM coverage harness
`scripts\run-windows-vm-coverage.ps1` walks every scenario directory under `vm\<scenario>\install.toml` and delegates per-scenario in-guest assertions to `scripts\windows-vm\coverage\<scenario>.ps1`. The bundled scenarios (`self-test`, `uac`, `hklm-registry`, `reboot`, `bundled-exec`) exercise the elevation, MoveFileEx pending-rename, HKLM-registry, and embedded-bundle code paths. The harness builds the release binary and dispatches scenarios in-place; pass `-SkipBuild` to reuse a prior build.
Generated
+465
View File
@@ -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"
+25
View File
@@ -0,0 +1,25 @@
[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_RestartManager",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[build-dependencies]
embed-manifest = "1"
+212
View File
@@ -0,0 +1,212 @@
# covenant-setup
copilot-pull-request-reviewer[bot] commented 2026-04-29 01:36:52 +00:00 (Migrated from github.com)
Review

The README links use absolute local Windows paths (e.g., C:\Users\...\workspace\...) which will be broken for other contributors and on GitHub. Replace these with repo-relative links (e.g., src/win.rs, src/main.rs, etc.) throughout the README so they render correctly in the repository.

- Uses Win32 APIs through the `windows` crate with unsafe isolated in [`src/win.rs`](src/win.rs)
The README links use absolute local Windows paths (e.g., `C:\Users\...\workspace\...`) which will be broken for other contributors and on GitHub. Replace these with repo-relative links (e.g., `src/win.rs`, `src/main.rs`, etc.) throughout the README so they render correctly in the repository. ```suggestion - Uses Win32 APIs through the `windows` crate with unsafe isolated in [`src/win.rs`](src/win.rs) ```
copilot-pull-request-reviewer[bot] commented 2026-04-29 01:36:53 +00:00 (Migrated from github.com)
Review

The README’s early bullets say the engine is “observing” everything a program does during install and post-install scripts, but later sections (and the codebase) indicate journaling is currently based on declared actions (and script execution is logged, not its internal mutations). Consider rewording these bullets to match current behavior so users don’t overestimate what gets rolled back.

- Recording the install actions it applies (files, directories, registry entries, shortcuts, and script execution) into a `journal.json` written alongside the installed application. This journal is then used during uninstall to reverse those recorded actions and clean up associated state.
- Take a "leave the campground better than you found it" approach - this Eagle Scout practices Leave No Trace.
- Taking a "trust but verify model" to program installs and uninstalls by journaling engine-applied mutations and logging script execution in order to respect the user.
- Using the `journal.json` as a manifest of the actions the installer performed during install and post-install processing.
The README’s early bullets say the engine is “observing” everything a program does during install and post-install scripts, but later sections (and the codebase) indicate journaling is currently based on declared actions (and script execution is logged, not its internal mutations). Consider rewording these bullets to match current behavior so users don’t overestimate what gets rolled back. ```suggestion - Recording the install actions it applies (files, directories, registry entries, shortcuts, and script execution) into a `journal.json` written alongside the installed application. This journal is then used during uninstall to reverse those recorded actions and clean up associated state. - Take a "leave the campground better than you found it" approach - this Eagle Scout practices Leave No Trace. - Taking a "trust but verify model" to program installs and uninstalls by journaling engine-applied mutations and logging script execution in order to respect the user. - Using the `journal.json` as a manifest of the actions the installer performed during install and post-install processing. ```
`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
- Recording the install actions it applies (files, directories, registry entries, shortcuts, and script execution) into a `journal.json` written alongside the installed application. This journal is then used during uninstall to reverse those recorded actions and clean up associated state.
- Take a "leave the campground better than you found it" approach - this Eagle Scout practices Leave No Trace.
- Taking a "trust but verify model" to program installs and uninstalls by journaling engine-applied mutations and logging script execution in order to respect the user.
- Using the `journal.json` as a manifest of the actions the installer performed during install and post-install processing.
Its current shape is:
- 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 a C# WinForms presentation process for GUI progress and prompts
- Sends GUI state over named-pipe IPC from the Rust engine to the C# UI
- Uses Win32 APIs through the `windows` crate with unsafe isolated in [`src/win.rs`](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.
When the .NET SDK is available, the Rust build publishes a self-contained C# WinForms UI helper and embeds it into the Rust executable. Without that helper the installer still builds, but `--headed` falls back to terminal progress.
## Install and Uninstall Model
Direct engine commands:
```powershell
cargo run -- --headless install path\to\install.toml
cargo run -- --headless 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 must be explicit for interactive runs.
Explicit flags:
- `--headless`: force TUI
- `--headed`: force GUI
- `--json`: suppress UI and emit machine-readable events
If `--headed` is requested but the C# UI helper is not bundled and no `Covenant.Setup.Ui.exe` sidecar exists next to the installer, the engine falls back to `--headless`.
### GUI
Current GUI behavior includes:
- C# WinForms prompts for confirmation and completion
- a progress window with:
- progress bar
- current operation text
- scrolling operations log
- reboot prompt when uninstall requires reboot to finish some cleanup
The Rust install engine owns all business logic and file/registry mutations. The C# process is presentation-only and receives JSON messages over a Windows named pipe.
### TUI
Current TUI behavior includes:
- `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)
- External-boundary calls (Win32, GUI prompts, reboot/cleanup spawning, embedded-bundle probe) flow through the `Sys` trait in [`src/sys.rs`](C:\Users\jasonross\workspace\covenant-setup\src\sys.rs); the production `WinSys` impl delegates to the real subsystems while `MockSys` records every call for unit tests
- Journaling currently records declared actions through `DeclaredTracker`
- The implementation is Windows-specific
## Current Limitations
- The GUI helper is embedded as a self-contained C# WinForms executable, which makes the installer binary substantially larger
- The installer is not yet generating branded/custom themed installer screens
- The manifest schema is still MVP-level and does not cover all production installer concerns
- Script execution logs the script invocation; internal script mutations are not observed beyond declared purge coverage
- The packager currently embeds payload in an appended raw bundle; this is functional but not yet compressed, signed, or tamper-resistant
- No signing, MSI generation, compression, delta updates, or patching pipeline exists yet
- No automated test suite has been added yet for end-to-end installer scenarios
## Verification Status
The codebase currently builds and formats successfully with:
```powershell
cargo fmt
cargo check
cargo build --release
```
Interactive GUI/TUI flows now have a Windows VM smoke harness for packaged installer behavior, while broader automated coverage is still limited.
## Linux Cross-Build (Optional)
Day-to-day work happens on Windows. This section documents an opt-in path for type-checking and running unit tests from a Linux host (useful for CI containers or quick iteration without a VM).
Prerequisites (Ubuntu 24.04 names):
- `dotnet-sdk-10.0` — optional for embedding the C# UI helper; without it, headed mode falls back to headless.
- `mingw-w64` — provides the `x86_64-w64-mingw32-*` toolchain that the `windows-gnu` target links against.
- `wine` — runs the resulting test binary. Already wired up via `runner = "wine"` in [`.cargo/config.toml`](.cargo/config.toml) for the `x86_64-pc-windows-gnu` target only.
- `rustup target add x86_64-pc-windows-gnu`.
The dotnet SDK needs `EnableWindowsTargeting=true` to cross-build a `net10.0-windows` project from Linux:
```bash
EnableWindowsTargeting=true cargo check --target x86_64-pc-windows-gnu
EnableWindowsTargeting=true cargo test --target x86_64-pc-windows-gnu
```
Caveats:
- `cargo run` (and the `package`/`install`/`uninstall` flows generally) require real Win32 — Wine handles unit-test execution but is not a substitute for the Windows VM smoke harness.
- The `windows-msvc` target (the default on Windows) is unaffected by this section.
## Windows VM Smoke Test
A Windows Hyper-V Vagrant VM now lives in [`Vagrantfile`](C:\Users\jasonross\workspace\covenant-setup\Vagrantfile), and the host harness in [`scripts/run-windows-vm-smoke.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\run-windows-vm-smoke.ps1) packages `covenant-setup`, boots the VM, opens Hyper-V's console viewer, and runs the packaged installer inside the guest's interactive desktop session.
The self-install manifest used for this path lives at [`vm/self-test/install.toml`](C:\Users\jasonross\workspace\covenant-setup\vm\self-test\install.toml). The guest verifies that install produced:
- `%LOCALAPPDATA%\CovenantSetupSelfTest\bin\covenant-setup.exe`
- `%LOCALAPPDATA%\CovenantSetupSelfTest\journal.json`
- `%LOCALAPPDATA%\CovenantSetupSelfTest\covenant-setup-uninstall.exe`
- `HKCU\Software\CovenantSetupSelfTest\InstallRoot`
- `Desktop\Covenant Setup Self Test.lnk`
It then immediately invokes the installed uninstaller with the generated journal and verifies that the install root, payload, journal, uninstaller, shortcut, application registry key, and Installed Apps registration are removed.
Run the smoke test from the repo root:
```powershell
$env:COVENANT_WINDOWS_BOX = "gusztavvargadr/windows-11"
$env:COVENANT_HYPERV_SWITCH = "Default Switch"
.\scripts\run-windows-vm-smoke.ps1
```
Notes:
- The Vagrant provider is `hyperv`, and the box you choose must support that provider.
- The harness opens `vmconnect.exe` after `vagrant up` so the guest desktop stays visible during the install.
- The default Vagrant synced folder is disabled to avoid SMB credential prompts; the harness uploads the installer and guest scripts over WinRM instead.
- The guest install is launched through an interactive scheduled task because WinRM sessions are not desktop-visible.
- The packaged installer now has a hidden automation mode that suppresses blocking GUI prompts while leaving the C# progress window visible for the VM smoke test.
- The guest scripts write a trace bundle under `dist\vagrant-self-test\trace` after each smoke run. It includes `guest-events.jsonl`, scheduler/process/event snapshots, Rust heartbeat files named `installer-heartbeat-*.jsonl`, and C# UI pipe logs named `csharp-ui-pipe-*.jsonl`.
- The Windows box should auto-log the `vagrant` user into the desktop session for the visual install path to appear.
- Set `COVENANT_HYPERV_SWITCH` to the Hyper-V virtual switch name you want Vagrant to use.
- The harness writes its verification artifact to `dist\vagrant-self-test\guest-result.json`.
- Use `-SkipViewer` if you do not want the harness to open the Hyper-V console window.
### VM Coverage Harness
In addition to the single-scenario smoke test, [`scripts/run-windows-vm-coverage.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\run-windows-vm-coverage.ps1) walks every scenario manifest under `vm\<scenario>\install.toml` (`self-test`, `uac`, `hklm-registry`, `reboot`, `bundled-exec`) and delegates the in-guest assertions to [`scripts\windows-vm\coverage\<scenario>.ps1`](C:\Users\jasonross\workspace\covenant-setup\scripts\windows-vm\coverage). The scenarios exercise the elevation, MoveFileEx pending-rename / Restart Manager, HKLM-only registry, and bundled embedded-installer code paths that the unit tests stub out via `MockSys`.
- Use `-HaltAfter` or `-DestroyAfter` if you want the harness to stop the VM after the test run.
Vendored
+36
View File
@@ -0,0 +1,36 @@
WINDOWS_BOX = ENV.fetch("COVENANT_WINDOWS_BOX", "gusztavvargadr/windows-11")
WINDOWS_BOX_VERSION = ENV["COVENANT_WINDOWS_BOX_VERSION"]
VM_NAME = ENV.fetch("COVENANT_VM_NAME", "covenant-setup-windows")
VM_MEMORY = ENV.fetch("COVENANT_VM_MEMORY", "6144")
VM_CPUS = ENV.fetch("COVENANT_VM_CPUS", "4")
WINRM_USERNAME = ENV.fetch("COVENANT_WINRM_USERNAME", "vagrant")
WINRM_PASSWORD = ENV.fetch("COVENANT_WINRM_PASSWORD", "vagrant")
HYPERV_SWITCH = ENV.fetch("COVENANT_HYPERV_SWITCH", "Default Switch")
Vagrant.configure("2") do |config|
config.vm.box = WINDOWS_BOX
if WINDOWS_BOX_VERSION && !WINDOWS_BOX_VERSION.empty?
config.vm.box_version = WINDOWS_BOX_VERSION
end
config.vm.hostname = VM_NAME
config.vm.guest = :windows
config.vm.communicator = "winrm"
config.vm.boot_timeout = ENV.fetch("COVENANT_BOOT_TIMEOUT", "1800").to_i
config.vm.graceful_halt_timeout = 180
config.vm.box_check_update = false
config.vm.network "public_network", bridge: HYPERV_SWITCH
config.vm.synced_folder ".", "/vagrant", disabled: true
config.winrm.username = WINRM_USERNAME
config.winrm.password = WINRM_PASSWORD
config.winrm.retry_limit = 60
config.winrm.retry_delay = 10
config.winrm.timeout = 1800
config.vm.provider "hyperv" do |h|
h.vmname = VM_NAME
h.memory = VM_MEMORY.to_i
h.cpus = VM_CPUS.to_i
end
end
+70
View File
@@ -0,0 +1,70 @@
use embed_manifest::embed_manifest;
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
println!("cargo:rustc-check-cfg=cfg(covenant_setup_embedded_ui)");
publish_csharp_ui();
embed_manifest(embed_manifest::new_manifest("Comctl32"))
.expect("unable to embed application manifest");
}
fn publish_csharp_ui() {
println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj");
println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/Program.cs");
println!("cargo:rerun-if-changed=ui/Covenant.Setup.Ui/app.manifest");
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
let project = manifest_dir.join("ui/Covenant.Setup.Ui/Covenant.Setup.Ui.csproj");
let publish_dir = out_dir.join("csharp-ui");
let dotnet_home = out_dir.join("dotnet-home");
let status = Command::new("dotnet")
.env("DOTNET_CLI_HOME", &dotnet_home)
.env("DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1")
.env("DOTNET_CLI_TELEMETRY_OPTOUT", "1")
.arg("publish")
.arg(&project)
.arg("--nologo")
.arg("--configuration")
.arg("Release")
.arg("--runtime")
.arg("win-x64")
.arg("--self-contained")
.arg("true")
.arg("-p:PublishSingleFile=true")
.arg("-p:IncludeNativeLibrariesForSelfExtract=true")
.arg("-p:PublishTrimmed=false")
.arg("-p:DebugType=none")
.arg("-p:DebugSymbols=false")
.arg("-o")
.arg(&publish_dir)
.status();
let status = match status {
Ok(status) => status,
Err(err) => {
println!(
"cargo:warning=C# UI helper was not bundled because dotnet publish could not start: {err}"
);
return;
}
};
if !status.success() {
println!(
"cargo:warning=C# UI helper was not bundled because dotnet publish failed with {status}"
);
return;
}
let ui_exe = publish_dir.join("Covenant.Setup.Ui.exe");
if !ui_exe.exists() {
println!(
"cargo:warning=C# UI helper was not bundled because dotnet publish did not produce {}",
ui_exe.display()
);
return;
}
println!("cargo:rustc-cfg=covenant_setup_embedded_ui");
println!("cargo:rustc-env=COVENANT_SETUP_UI_EXE={}", ui_exe.display());
}
+296
View File
@@ -0,0 +1,296 @@
# Code Review: feat-mvp
## Overview
A Windows installer engine that: parses a TOML manifest → executes mutations via Win32 → journals each action → reverses on uninstall. Three CLI verbs (package, install, uninstall) plus a hidden cleanup. Adds a single-file packager that appends payload+index+magic-footer onto the EXE, an out-of-process WinForms GUI (C# binary embedded at build time, talks to Rust over a named pipe, JSON-per-line), a TUI spinner mode, and a --json IPC mode. Tracking goes through the MutationTracker trait (DeclaredTracker is the only impl, matching the MVP spec).
## What's Solid
- Adherence to the MVP spec: W APIs everywhere, KEY_WOW64_64KEY set on every RegCreateKeyExW, SHGetKnownFolderPath for {ProgramFilesX64} / {LocalAppData} / {Desktop}, Restart Manager (RmStartSession/RmGetList) + MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) fallback for locked files, runas elevation via ShellExecuteW, exit code 33 for elevation-required.
- Glass-box logging: every unsafe block is bracketed by unsafe_enter/unsafe_exit (src/win.rs), and trace_event writes JSONL heartbeat for debugging. This is the most distinctive strength of the code.
- Module discipline: src/win.rs owns 100% of FFI; no unsafe leaks into main.rs. Utf16Arg correctly null-terminates and exposes as_bytes() with terminator (right for REG_SZ).
- Self-deletion strategy: spawn helper EXE → original exits → helper deletes target + schedules its own cleanup via PowerShell + MoveFileEx reboot fallback. Sound design.
- Bundle format (src/main.rs:577–687): payload + length-prefixed JSON index + payload-len + magic footer is a clean append-only design that survives any leading
binary signing layout.
## Correctness Issues
- [x] path_requires_admin (src/main.rs:1844) hardcodes c:\\program files / c:\\windows. This contradicts the MVP requirement "Hardcoded paths are forbidden" and the convention in CLAUDE.md. Compare against FOLDERID_ProgramFiles* / FOLDERID_Windows from PathResolver. Will misdetect on a non-C: Windows install. Resolved: admin checks now route through PathResolver roots.
- [x] relaunch_as_admin (src/win.rs:162): std::env::args().skip(1).collect().join(" ") does not Windows-quote arguments. A manifest path with spaces ("C:\Users\Alice's Apps\install.toml") survives as separate tokens after runas. Use CommandLineToArgvW-compatible quoting. Resolved: args are quoted with CommandLineToArgvW-compatible rules.
- [x] select_ui defaults are inverted (src/main.rs:1474): when stdout is a terminal but parent isn't PowerShell, returns UiMode::None (silent install with no progress); when stdout is not a terminal (piped/redirected), returns UiMode::Gui. So installer install foo.toml | tee log.txt pops a GUI. Default for terminals should be TUI. Resolved differently: UI mode is now explicit; --json suppresses UI and --headed falls back to headless if GUI is unavailable.
- [x] is_bundled_runtime_invocation (src/main.rs:1460) scans all args for package|install|uninstall|cleanup. If any value (e.g. a path, hidden value, future
positional) ever equals one of these strings, routing breaks. Inspect only the first non-flag positional. Resolved: the pre-clap routing helper was removed; clap now parses optional subcommands and bundled mode is selected only when no subcommand is present and an embedded bundle exists.
- [x] fail_gui_progress vs finish_gui_progress (src/main.rs:1547,1558) are identical — both call progress.finish(message). There's no fail message type to the C# side,
so a failed install gets a "completed" UX. Either add a "fail" message variant or red-state the C# form on a known sentinel. Resolved: GUI progress now has a fail IPC message, persistent failure UX, and errata export.
- [x] install_uninstaller records seven separate WriteRegistry actions for the ARP key (src/main.rs:962), but uninstall short-circuits to delete_registry_tree on first match (src/main.rs:1086). Functionally fine; the other six are dead journal entries. Either record one branch action or deduplicate during rollback. Resolved: uninstall defers each uninstall-registry branch only once.
- [x] remove_directory_if_exists (src/win.rs:228) silently swallows ERROR_DIR_NOT_EMPTY and returns Ok without surfacing it to the journal/UI. Worth at least a warn-level event so users know residue exists. Resolved: not-empty directories emit a `remove_directory_deferred` event with reason `not_empty`.
- [x] same_path (src/main.rs:1834) is a lowercased string compare. Doesn't handle \\?\ prefixes, 8.3 names, or junctions. Use dunce::canonicalize /
std::fs::canonicalize with a string-fallback for missing paths. Resolved: same_path canonicalizes both sides when possible and falls back to normalized string comparison with verbatim-prefix handling.
- [x] collect_bundle_files_recursive (src/main.rs:548) has no exclude list. Re-running package from a directory that was previously installed-from will pick up
journal.json (and any temp scratch) into the new bundle. Resolved: bundle collection skips known generated artifacts and the current output installer path.
- [x] run_bundled_installer (src/main.rs:721) has a single-variant enum RuntimeMode::Bundled; match arm is dead branching. Either drop the enum or commit to
multi-mode. Resolved: RuntimeMode was removed.
- [x] Typo replicated: "uninstalled sucessfully" (missing 's') in src/main.rs:1235 and src/ui.rs:111. Resolved.
## Architecture / Style
- [ ] main.rs is 1856 lines mixing CLI, manifest types, journal types, install/uninstall logic, bundle (de)serialization, IPC plumbing, and a dozen helpers. Split into manifest.rs, journal.rs, bundle.rs, install.rs, uninstall.rs, cli.rs. The ui.rs / win.rs split is good — extend that pattern.
- [x] Manual arg parsing in parse_ui_preferences (src/main.rs:1433) duplicates clap. The bundled-runtime detection happens before clap parses, which is why this exists, but the duplication of the subcommand keyword list is brittle. Consider running Cli::try_parse_from in detect-only mode first, or feed clap a pre-stripped args vector. Resolved: parse_ui_preferences was removed and clap parses the optional subcommand path directly.
- [x] start_gui_progress ignores its app_name parameter (src/main.rs:1505); &format!("{title}") is a no-op clone. Remove the dead arg. Resolved.
- [x] UiPhase enum is effectively unused in select_ui — both arms return UiMode::None. Resolved: UiPhase was removed.
- [x] Many effective_logger.info("create_directory", json!({"path":path})) blocks are near-clones. A step! macro or per-action helper would shrink the install loop substantially. Resolved partially: repeated progress-step increment/advance plumbing now goes through `advance_gui_progress_step`.
## Tests
- [x] Zero automated tests (CLAUDE.md confirms). The smoke is end-to-end on a Vagrant Windows VM, which is good for integration but doesn't catch regressions cheaply. Resolved: unit tests now cover bundle/journal helpers plus Win32 quoting/admin-root matching.
Easy unit-test wins, all OS-portable:
- [x] Bundle round-trip (append_embedded_bundle → read_embedded_bundle)
- [x] Journal serde round-trip
- [x] parse_registry_key (HKCU, HKLM, error)
- [x] sanitize_registry_component (empty input, mixed punctuation)
- [x] same_path / normalize_path_for_compare
- [x] path_requires_admin (after de-hardcoding)
- [x] Utf16Arg::as_bytes length math
- [x] is_bundled_runtime_invocation truth table. Resolved by removing the helper and testing clap parsing for bundled flags without manual preparse.
## Security
Threat model is "developer authors a trusted manifest" — under that assumption, mostly fine. Concrete items:
- Bundle has no integrity check. Anyone who can write to the EXE can swap the appended payload without breaking Authenticode signing of the original PE. For a shipping installer, hash the embedded bundle into the binary at build time and verify on read.
- execute_script is by-design arbitrary code execution under the elevation context — document this in the manifest schema. Consider a --no-scripts switch for
paranoid environments.
- PowerShell single-quote escape (powershell_single_quote) is correct for single-quoted strings. Good.
- Registry component sanitizer maps anything outside [A-Za-z0-9_-] to _. Good against subkey-traversal injection.
- extract_embedded_bundle writes to %TEMP%\covenant-setup\{stem}-{pid} and remove_dir_alls any existing path first — TOCTOU window if a hostile user has write access to that temp tree. Low risk on Windows ACLs, but consider creating with a random suffix.
## Performance
- read_embedded_bundle reads the entire EXE into memory (read_to_end, src/main.rs:617). For an installer with a multi-hundred-MB payload, this doubles peak RSS.
Seek to len - 32 to read footer, then seek back to payload_offset and stream into the extraction directory.
- extract_embedded_bundle clones each file's bytes from the in-memory bundle to disk; combined with the above, the payload is held in RAM twice. Streaming
extraction would fix both.
Verdict
For an MVP this is in good shape and tracks the spec closely. The biggest deltas to address before calling it MVP-complete:
- [x] De-hardcode path_requires_admin (spec violation).
- [x] Quote args in relaunch_as_admin (real-world breakage on paths-with-spaces).
- [x] Fix the select_ui default (silent installs from terminals, GUIs from pipes). Resolved by requiring explicit UI mode and honoring --json.
- [x] Add a handful of unit tests for the pure-Rust helpers — bundle round-trip and journal serde at minimum.
- [x] Decide whether fail_gui_progress should actually surface failure UX.
### Q: How would you suggest fixing item 1?
**A:** The fix is to resolve the admin-required roots at runtime through PathResolver (same pattern as {ProgramFilesX64} etc.), then test ancestry against those resolved roots.
Concrete change
1. Add the missing known folders to PathResolver (src/win.rs:39):
```rust
use windows::Win32::UI::Shell::{
FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64,
FOLDERID_ProgramFilesX86, FOLDERID_Windows, /* … */
};
pub struct PathResolver {
pub program_files_x64: PathBuf,
pub program_files_x86: PathBuf,
pub windows_dir: PathBuf,
pub local_app_data: PathBuf,
pub desktop: PathBuf,
admin_roots: Vec<String>, // pre-lowercased for matching
}
impl PathResolver {
pub fn new(logger: &Logger) -> Result<Self, AppError> {
let program_files_x64 = known_folder(&FOLDERID_ProgramFilesX64, logger)?;
let program_files_x86 = known_folder(&FOLDERID_ProgramFilesX86, logger)?;
let windows_dir = known_folder(&FOLDERID_Windows, logger)?;
let admin_roots = [&program_files_x64, &program_files_x86, &windows_dir]
.iter()
.map(|p| p.to_string_lossy().to_ascii_lowercase())
.collect();
Ok(Self {
program_files_x64,
program_files_x86,
windows_dir,
local_app_data: known_folder(&FOLDERID_LocalAppData, logger)?,
desktop: known_folder(&FOLDERID_Desktop, logger)?,
admin_roots,
})
}
pub fn requires_admin(&self, path: &Path) -> bool {
let candidate = path.to_string_lossy().to_ascii_lowercase().replace('/', "\\");
self.admin_roots.iter().any(|root| {
candidate == *root
|| candidate.starts_with(&format!("{root}\\"))
})
}
}
```
The trailing \ check avoids matching C:\Program Files Custom against C:\Program Files.
2. Delete the standalone heuristic (src/main.rs:1844) and route callers through the resolver:
```rust
fn manifest_requires_admin(
manifest: &InstallManifest,
resolver: &win::PathResolver,
) -> Result<bool, AppError> {
let mut paths = Vec::new();
paths.extend(manifest.directories.iter().map(|d| &d.path));
paths.extend(manifest.files.iter().map(|f| &f.destination));
paths.extend(manifest.shortcuts.iter().map(|s| &s.path));
if paths.iter().any(|p| resolver.requires_admin(&resolver.resolve(p))) {
return Ok(true);
}
for key in &manifest.registry {
if matches!(parse_registry_key(&key.key)?.0, RegistryRoot::Hklm) {
return Ok(true);
}
}
Ok(false)
}
```
journal_requires_admin collapses similarly — it already takes a resolver, so just call resolver.requires_admin(path) instead of the free function.
#### Why this approach
- Removes the c:\ and \program files literals — the spec violation goes away.
- Works on non-C: Windows installs and on locale-translated path variants (German "Programme", etc., though modern Windows always returns the English form via SHGetKnownFolderPath).
- Catches Program Files (x86) explicitly via FOLDERID_ProgramFilesX86, which the current code only matched accidentally via the program files prefix.
- Reuses the resolver that's already constructed in both install and uninstall, so no extra Win32 calls per check.
#### Test it cheaply
Once requires_admin is a method, you can fake roots in a unit test without touching Win32:
```rust
#[test]
fn requires_admin_matches_subpaths_only() {
let r = PathResolver::with_roots_for_test(vec![
"C:\\Program Files".into(),
"D:\\Windows".into(),
]);
assert!(r.requires_admin(Path::new("C:\\Program Files\\App\\bin")));
assert!(r.requires_admin(Path::new("D:\\Windows\\System32")));
assert!(!r.requires_admin(Path::new("C:\\Program Files Custom\\App")));
assert!(!r.requires_admin(Path::new("C:\\Users\\alice")));
}
```
That requires a small test-only constructor (#[cfg(test)] fn with_roots_for_test) but it's the cheapest way to lock in the prefix-boundary behavior — exactly the case the current implementation gets wrong.
### Q: Fix the select_ui default (silent installs from terminals, GUIs from pipes). Explain this.
**A:** What select_ui does today
The function lives at src/main.rs:1474 and decides between UiMode::Tui (in-terminal spinner), UiMode::Gui (out-of-process WinForms), or UiMode::None (no UI at all). The current decision table:
┌─────────────────────────────────────────────────────┬────────┐
│ User input / environment │ Result │
├─────────────────────────────────────────────────────┼────────┤
│ --headless │ Tui │
├─────────────────────────────────────────────────────┼────────┤
│ --headed │ Gui │
├─────────────────────────────────────────────────────┼────────┤
│ stdout is a terminal and parent is PowerShell │ Tui │
├─────────────────────────────────────────────────────┼────────┤
│ stdout is not a terminal (pipe/redirect/no console) │ Gui │
├─────────────────────────────────────────────────────┼────────┤
│ stdout is a terminal but parent isn't PowerShell │ None │
└─────────────────────────────────────────────────────┴────────┘
Two of those rows produce the wrong UX.
#### Bug 1: silent installs in cmd.exe / Windows Terminal
A user opens cmd.exe (or a Windows Terminal tab hosting cmd, or double-clicks a .bat that runs the installer) and types covenant-setup install foo.toml. They are staring at a console. They expect to see something — a spinner, log lines, anything. The current code goes:
1. --headless / --headed → no, neither set.
2. is_terminal() && is_parent_powershell() → terminal yes, parent is cmd.exe not powershell.exe/pwsh.exe → no.
3. !is_terminal() → no, stdout is a terminal.
4. Falls through to UiPhase::Install => UiMode::None.
Result: silent install. The TUI spinner only fires when the parent process happens to be PowerShell, which discriminates against every other shell — cmd.exe, Git Bash, Cygwin, MSYS2, ConEmu hosts, anything spawned from a launcher, etc.
The PowerShell check (win::is_parent_powershell) was probably added because is_terminal() returns true for the PowerShell ISE / VS Code integrated terminal cases that handle ANSI well. But conflating "is a terminal" with "is a PowerShell terminal" is the wrong gate. Any TTY-attached stdout deserves TUI by default.
#### Bug 2: GUI pops up from pipes and CI logs
A CI script or a developer runs:
```
covenant-setup install foo.toml --json | tee install.log
covenant-setup install foo.toml > install.log 2>&1
```
Stdout is not a terminal (it's a pipe / file). The current rule:
```rust
if !io::stdout().is_terminal() {
return Ok(UiMode::Gui);
}
```
…spawns the WinForms process. On a CI runner with no interactive desktop session this either fails to render, blocks on a hidden modal, or — on a developer box — pops a window in front of whatever they were doing while their tee happily collects an empty log. Worse, the --json flag is not even consulted in this path — a JSON-mode consumer who explicitly opted into machine-readable output gets a GUI anyway.
The intent was clearly "if there's no console attached, we must be a double-clicked .exe → show a GUI." But is_terminal() == false doesn't mean "no console" — it means "stdout isn't a TTY," which is true for pipes, files, and detached subprocesses just as much as for window-launched processes.
#### What the heuristic should be
The decision tree should disambiguate three different concepts the current code is collapsing:
1. Did the user explicitly ask for a UI mode? → honor --headless / --headed.
2. Did the user explicitly ask for IPC mode? → --json implies UiMode::None.
3. Is stdout being consumed by something? → is_terminal() == false means "redirected"; respect that, don't pop a GUI.
4. Is there a console attached at all? → if there's no console, we're a windowed launch and Gui is correct.
A corrected table:
┌───────────────────────────────────────┬────────┐
│ Condition (checked in order) │ Result │
├───────────────────────────────────────┼────────┤
│ --json │ None │
├───────────────────────────────────────┼────────┤
│ --headless │ Tui │
├───────────────────────────────────────┼────────┤
│ --headed │ Gui │
├───────────────────────────────────────┼────────┤
│ stdout is a terminal (any shell) │ Tui │
├───────────────────────────────────────┼────────┤
│ stdout redirected, console attached │ None │
├───────────────────────────────────────┼────────┤
│ no console attached (windowed launch) │ Gui │
└───────────────────────────────────────┴────────┘
The "no console attached" check is the missing primitive. On Windows it's GetConsoleWindow() == NULL or, equivalently, querying whether the process inherited a console handle. That replaces the brittle parent-process sniff. is_parent_powershell can be retired entirely — its only legitimate signal (the host is interactive) is already captured by is_terminal().
#### Concrete implementation sketch
Three small pieces:
1. Add --json to UiPreferences (src/main.rs:343) and propagate it through ui_preferences_from_cli and parse_ui_preferences. Right now Logger::json is the only place the flag lands.
2. Add win::has_console() -> bool in src/win.rs calling GetConsoleWindow (cheap, no allocation, single Win32 call). Bracket it with the same unsafe_enter/unsafe_exit pattern used elsewhere.
3. Rewrite select_ui to the table above. The UiPhase enum becomes unused (both arms returned the same value anyway) and can be deleted along with all call sites. is_parent_powershell and its 60-line CreateToolhelp32Snapshot walk in src/win.rs:45 can also go.
Net diff is probably +20 / −80 lines, with a clear behavioral improvement and dead-code reduction.
#### Why it matters beyond cosmetics
- CI correctness: an installer that pops a GUI on a headless agent is a hard-to-diagnose hang or a flaky failure. Getting --json mode to actually mean "no UI ever" is the difference between a usable and unusable IPC contract.
- Spec compliance: project_mvp.md says JSON mode "must suppress" all UI. The current code silently violates this whenever stdout is also redirected — which is exactly the case JSON mode is for.
- User trust in cmd.exe: silently completing a multi-step install with no feedback feels broken even when it succeeds. First-run perception of an installer is dominated by what happens in the first second.
### Feedback: Do piece 1, but then simplify
Just force the user to pass either --headed or --headless, with an error message if not supplied, and a fallback to --headless if the C# binary is either not bundled with the installer or not present on the system to install to.
+321
View File
@@ -0,0 +1,321 @@
# Integration Tests Architecture
## Background
Rust unit-test coverage stalled at **70.09% line coverage**
(main.rs 74.52%, ui.rs 31.44%, win.rs 75.99%).
The remaining uncovered lines are at hard external boundaries that cannot be
exercised by pure unit tests:
| Boundary | Why it can't be unit-tested directly |
|---|---|
| UAC relaunch (`ShellExecuteW` with verb `runas`) | Spawns a new elevated process; no return value to observe |
| Reboot prompt + `shutdown.exe` spawn | System-level side effect; mutates host state |
| Cleanup-helper self-delete (copy exe → temp → `cmd /c del`) | Operates on the current process's own binary |
| HKLM registry writes | Requires admin; state persists on the host |
| Bundled-installer execution (`has_embedded_bundle()` + dispatch) | Requires a self-contained EXE with appended payload |
| Live GUI progress IPC (`CSharpUiSession` named-pipe child) | Spawns a real WinForms process |
| `MoveFileEx` reboot fallback for locked files | Requires a second process holding a file handle |
The solution is a two-layer approach:
1. **Trait-based mocking** for unit tests — inject a recording `MockSys` so
the orchestration logic can be driven without any Win32/process side effects.
2. **Vagrant VM integration tests** for real boundary validation — run each
scenario inside a fresh Hyper-V Windows 11 guest.
---
## Trait Layer (`src/sys.rs` and `src/ui.rs`)
### `Sys` trait (`src/sys.rs`)
```
pub(crate) trait Sys: Send + Sync { … }
```
Groups all seven external boundaries into a single injectable surface.
The production implementation `WinSys` delegates each method to the existing
free functions in `win.rs`, `ui.rs`, and `main.rs`:
```
Sys method → delegates to
─────────────────────────────────────────────────────────────────────────────
is_elevated / relaunch_as_admin → win::is_elevated / win::relaunch_as_admin
spawn_reboot → spawn_reboot() (main.rs)
prompt_reboot_tui → prompt_reboot_tui() (main.rs)
spawn_cleanup_helper → spawn_cleanup_helper() (main.rs)
schedule_helper_self_cleanup → schedule_helper_self_cleanup() (main.rs)
set_registry_string → win::set_registry_string
delete_registry_tree → win::delete_registry_tree
has_embedded_bundle → has_embedded_bundle() (main.rs)
ui_available / ui_confirm_install
/ ui_report_success / … → ui::* free functions
remove_file_with_fallback → win::remove_file_with_fallback
```
All Win32 functions remain in `win.rs`. `sys.rs` contains no `unsafe` code;
it is purely a delegation and trait-abstraction layer.
An optional `start_progress` method (default returns `None`) lets `MockSys`
inject a recording `ProgressSink` into install/uninstall without touching the
real C# UI.
### `ProgressSink` trait (`src/ui.rs`)
```
pub trait ProgressSink: Send {
fn advance(&mut self, current_step, message) → Result<(), AppError>;
fn log(&mut self, message) → Result<(), AppError>;
fn finish(&mut self, message) → Result<(), AppError>;
fn fail(&mut self, app_name, operation, message, error, errata, wait_for_close)
→ Result<(), AppError>;
}
```
`GuiProgress` implements this trait. Install/uninstall functions now accept
`Option<Box<dyn ProgressSink>>` rather than `Option<GuiProgress>` directly,
allowing injection of a no-op or recording sink in tests.
### Call-site threading
The `&dyn Sys` reference flows through:
```
main()
└── run(cli, sys, logger)
├── run_bundled_installer(prefs, sys, logger)
├── install(manifest, opts, sys, logger) → Option<Box<dyn ProgressSink>>
│ └── register_uninstall_entry(…, sys, logger)
├── uninstall(journal, opts, sys, logger)
└── cleanup(…, sys, logger)
└── ensure_elevation_if_needed(…, sys, logger)
```
The production `WinSys` value is constructed once in `main()` and borrowed
everywhere below. Existing tests that call these functions directly pass
`&WinSys` unchanged; mock tests pass `&MockSys`.
---
## Mock Layer (in `src/main.rs` `#[cfg(test)]`)
### `MockSys`
```rust
struct MockSys {
is_elevated: Mutex<bool>, // programmable probe result
ui_confirm: Mutex<bool>, // programmable confirm result
reboot_prompt: Mutex<bool>, // programmable reboot prompt result
schedule_cleanup_returns: Mutex<bool>,
has_bundle: bool,
calls: Mutex<Vec<SysCall>>, // all recorded calls
}
```
Every `Sys` method appends a `SysCall` enum variant to `calls` before
returning. Tests assert on the recorded call sequence:
```rust
let sys = MockSys::new();
ensure_elevation_if_needed(true, true, &sys, &logger)?;
assert!(sys.recorded().contains(&SysCall::RelaunchAsAdmin));
```
### `MockProgressSink`
Records `advance`, `log`, `finish`, and `fail` calls in a `Vec<ProgressCall>`.
Injected via `MockSys::start_progress` so the install codepath exercises all
`advance_gui_progress` / `finish_gui_progress` / `fail_gui_progress` calls.
### New unit tests (14 total, in `mod tests`)
| Test | Boundary exercised |
|---|---|
| `ensure_elevation_if_needed_relaunches_when_required_and_relaunch_flag_set` | UAC relaunch path |
| `ensure_elevation_if_needed_errors_when_required_and_no_relaunch` | UAC error message |
| `ensure_elevation_if_needed_passes_when_already_elevated` | UAC no-op path |
| `cleanup_prompts_and_spawns_reboot_when_required_in_gui_mode` | Reboot spawn |
| `cleanup_skips_reboot_when_user_declines` | Reboot prompt negative |
| `cleanup_tui_path_skips_prompt_when_no_reboot_needed` | Cleanup TUI path |
| `register_uninstall_entry_writes_all_seven_values` | Registry write count |
| `run_bundled_installer_dispatches_install_and_reports_success_in_gui` | Bundled exec + UI report |
| `run_bundled_installer_reports_error_when_install_fails` | UI error path |
| `install_emits_set_registry_string_calls_for_each_registry_spec` | Registry write content |
| `uninstall_calls_delete_registry_tree_for_recorded_actions_and_purge` | Registry delete order |
| `uninstall_calls_remove_file_with_fallback_for_copy_actions_and_shortcuts` | MoveFileEx delegation |
| `uninstall_defers_self_delete_to_spawn_cleanup_helper` | Cleanup helper dispatch |
| `progress_sink_mock_records_calls_through_advance_log_finish_fail` | ProgressSink recording |
---
## Vagrant Integration Tests
Real boundary validation runs inside a Hyper-V Windows 11 VM
(`gusztavvargadr/windows-11`). Every install/uninstall side effect stays
inside the VM; the host only builds the binary and drives Vagrant over WinRM.
### File layout
```
vm/
self-test/install.toml Legacy smoke test (HKCU + LocalAppData)
uac/install.toml ProgramFiles target → forces elevation probe
hklm-registry/install.toml HKLM registry key → forces elevation via root
reboot/install.toml Payload + script that self-locks file
bundled-exec/install.toml Packaged installer bundle (HKCU + LocalAppData)
scripts/
run-windows-vm-coverage.ps1 Host-side orchestrator
windows-vm/coverage/
self-test.ps1 Guest-side assertion script
uac.ps1
hklm-registry.ps1
reboot.ps1
bundled-exec.ps1
```
### Orchestrator (`scripts/run-windows-vm-coverage.ps1`)
Mirrors the pattern of `run-windows-vm-smoke.ps1`:
1. `cargo build --release` on the host (skippable with `-SkipBuild`).
2. Stages `payload\covenant-setup.exe` into each scenario directory that
references it (manifests that do file installs need a payload binary).
3. `vagrant up --provider hyperv` (skippable with `-SkipVmBoot`).
4. Waits for the Windows explorer shell to be responsive.
5. Uploads `covenant-setup.exe` + all scenario directories + all guest scripts
into `C:\Users\vagrant\AppData\Local\Temp\covenant-setup-coverage\` on the
guest.
6. For each scenario:
- WinRM-invokes `<scenario>.ps1 -Exe … -Manifest … -WorkRoot …` in the guest.
- Captures guest log via a second WinRM call.
- Records `{ scenario, success, exitCode }`.
7. Writes `dist\vagrant-coverage\summary.json` with aggregated results.
8. Optionally halts or destroys the VM (`-HaltAfter` / `-DestroyAfter`).
Guest scripts run with `Set-StrictMode -Version Latest` and
`$ErrorActionPreference = 'Stop'`, matching the existing harness conventions.
### Scenario descriptions
#### `self-test`
Baseline parity with the legacy smoke test. Installs to `%LocalAppData%`,
asserts the journal records directory/file/registry/shortcut actions,
then runs uninstall and verifies all recorded paths are removed.
#### `uac`
Manifest targets `{ProgramFilesX64}`, which makes the elevation probe flag the
install as needing admin. The script asserts:
1. Running without `--elevate` fails with exit ≠ 0 and the message
`"Elevation required"`.
2. Running with `--elevate` inside the already-elevated WinRM session succeeds.
3. Elevated uninstall cleans up without error.
#### `hklm-registry`
Manifest writes a key under `HKLM\Software\…`, which triggers the
registry-root elevation check independently of file paths. Assertions mirror
the UAC scenario: fail without `--elevate`, succeed with it, verify the
journal records an HKLM `write_registry` action.
#### `reboot`
Installs a file payload, then the scenario script locks the installed binary
by spawning it in a background process before running uninstall. The uninstaller
must fall back to `MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT)` via the Restart
Manager path. The script asserts the uninstall log contains a
`reboot_required` / `pending_rename` / `MoveFileEx` marker.
The background lock process is stopped after uninstall so the VM stays clean.
#### `bundled-exec`
Exercises the self-contained installer packaging pipeline end-to-end:
1. `covenant-setup package <manifest> --output <dir>` produces a bundled EXE.
2. The bundled EXE is invoked with **no subcommand** (`--json --headless
--automation install --journal …`), which triggers the
`has_embedded_bundle()` probe path in `main()`.
3. The journal is parsed and must contain at least one recorded action.
4. Standard uninstall cleans up.
---
## Running
### Unit tests (local, safe)
```powershell
# Rust: 96 tests including the 14 mock-based boundary tests.
cargo test
# C# UI: 36 xUnit tests covering pure helpers in Program.cs.
dotnet test ui\Covenant.Setup.Ui.Tests\Covenant.Setup.Ui.Tests.csproj
```
No Win32, registry, or process side effects in either suite.
#### C# UI unit tests (`ui/Covenant.Setup.Ui.Tests/`)
The WinForms host (`ui/Covenant.Setup.Ui/Program.cs`) follows the same
"extract pure logic, mock the boundary" pattern used on the Rust side:
| Helper (`internal static`) | What it does | Tests |
|---|---|---|
| `Program.ReadPipeName` | Parses `--pipe <name>` from `args` | 6 cases: present, case-insensitive flag, mid-args, missing, dangling flag, empty |
| `InstallerUiForm.BuildErrataJson` | Serializes `message.Errata` if present, else a synthesized `{app_name, operation, message, error}` payload | 3 branches: object errata, null `Errata`, JSON `null` element |
| `InstallerUiForm.SafeMessageSummary` | Best-effort `(type,id,message)` extraction for tracing; falls back to `{RawLength}` on parse failure | Valid JSON, missing fields, invalid JSON |
| `InstallerUiForm.MapButtons` / `MapIcon` / `MapDialogResult` | String ↔ WinForms enum translation between the IPC wire format and `MessageBox*` types | Exhaustive `[Theory]` tables incl. defaults and `DialogResult.Abort/Retry/Ignore` |
| `UiMessage` / `UiResponse` JSON contract | Snake-case ↔ PascalCase mapping (`app_name`, `current_step`, `total_steps`, etc.) | Round-trip tests covering progress, fail (with errata), prompt, missing-type |
Conventions:
- Production members are `internal` (not `public`); the production csproj
declares `<InternalsVisibleTo Include="Covenant.Setup.Ui.Tests" />` so the
test assembly can reach them without widening the public API.
- Tests never instantiate `InstallerUiForm` directly — its constructor builds
real `Control` instances and is not unit-testable. Only static helpers are
exercised. Live form behaviour is covered by the GUI scenario in the
Vagrant harness instead.
- Anonymous-object return values (`SafeMessageSummary`) are asserted by
serializing the result and parsing the JSON, which avoids reflection-based
property lookups against the compiler-generated anonymous type.
### Integration tests (requires Hyper-V + Vagrant)
```powershell
# Full run: boot VM, run all scenarios, halt VM
.\scripts\run-windows-vm-coverage.ps1 -HaltAfter
# Skip rebuild if binary is already current
.\scripts\run-windows-vm-coverage.ps1 -SkipBuild -HaltAfter
# Skip VM boot if it's already running
.\scripts\run-windows-vm-coverage.ps1 -SkipVmBoot -HaltAfter
# Run only specific scenarios
.\scripts\run-windows-vm-coverage.ps1 -Scenarios @('uac','hklm-registry') -HaltAfter
```
Results are written to `dist\vagrant-coverage\summary.json`.
Per-scenario guest logs are in `dist\vagrant-coverage\<scenario>\guest.log`.
---
## Design decisions
**Single `Sys` trait rather than seven separate traits.** The orchestration
functions (`install`, `uninstall`, `cleanup`, etc.) each touch three or four
boundaries in combination. A single injectable surface keeps signature noise
minimal and makes `MockSys` straightforward to construct.
**No test-only methods on `Sys`.** `start_progress` has a production-viable
default (`None`), so the trait contains no `#[cfg(test)]` methods. The mock
simply overrides it.
**Win32 code stays in `win.rs`.** `sys.rs` contains zero `unsafe` blocks.
It delegates to the already-audited Win32 wrappers rather than duplicating them.
**Guest scripts are the assertion layer, not PowerShell DSL helpers.** Each
`scripts/windows-vm/coverage/<scenario>.ps1` is a self-contained script that
installs, asserts, and uninstalls. There is no shared PowerShell assertion
library to maintain.
**Vagrant is the only real-boundary test channel.** Boundaries involving
UAC, HKLM writes, locked files, and bundled execution are not exercised on the
host dev machine. The orchestrator will always fail if Vagrant is not available,
which is intentional.
+438
View File
@@ -0,0 +1,438 @@
# Vagrant Smoke Test Debugging Notes
This document records the work done while replacing the PowerShell UI with a C# presentation layer, adding guest-side diagnostics, and stabilizing the Windows Vagrant smoke test. It is intended as a reference for future installer hangs where the VM console is not visible.
## Scope
The work covered these areas:
- Removed the PowerShell-hosted UI path.
- Added a C# WinForms UI process.
- Connected Rust business logic to the C# UI over Windows named pipes.
- Added guest trace collection so hangs can be diagnosed without watching the VM console.
- Rebuilt and tested the packaged installer in the Hyper-V Vagrant guest.
- Extended the smoke test to install, verify installed state, uninstall, and verify removed state.
## Current Architecture
The installer is still driven by Rust. The C# process is presentation only.
- Rust business logic lives primarily in `src/main.rs`.
- Rust C# UI IPC lives in `src/ui.rs`.
- C# WinForms UI lives in `ui/Covenant.Setup.Ui/Program.cs`.
- `build.rs` publishes the C# UI as a self-contained `win-x64` single-file executable.
- The Rust binary embeds the published C# UI executable with `include_bytes!`.
- At runtime, Rust extracts the C# UI executable to `%TEMP%\covenant-setup-ui`, starts it, and connects to a named pipe.
- The C# UI owns the pipe server and reads newline-delimited JSON messages.
- Rust sends messages such as `init`, `progress`, `log`, `finish`, `prompt`, and `close`.
- The C# UI writes prompt responses back as JSON.
## Trace Outputs
The guest trace directory is:
```text
C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace
```
The host harness pulls this into:
```text
dist\vagrant-self-test\trace
```
Important trace files:
- `guest-events.jsonl`: host/guest harness events, scheduled task status, verification phases.
- `installer-heartbeat-<pid>.jsonl`: Rust process heartbeat and installer phases.
- `csharp-ui-pipe-<pid>.jsonl`: C# UI process and pipe receive/send events.
- `interactive-context.json`: context captured by the interactive wrapper.
- `interactive-processes.json`: relevant guest processes during wrapper diagnostics.
- `interactive-windows.json`: visible windows and process window titles.
- `interactive-application-events.json`: recent Application event log entries.
- `abort-*.json`: snapshots created by the abort collector.
The host harness always tries to pull the trace bundle in `finally`, even when the smoke test fails.
## Errors Encountered
### 1. Host Sandbox and Vagrant Permissions
Running Vagrant and Hyper-V actions from the coding sandbox required escalation. This affected commands such as:
```powershell
.\scripts\run-windows-vm-smoke.ps1 -SkipViewer -HaltAfter
vagrant status
vagrant winrm ...
vagrant upload ...
```
This was expected: Vagrant controls an external VM, uses WinRM, and interacts with Hyper-V.
### 2. Pre-main Guest Failure: Missing VCRUNTIME140.dll
The first meaningful guest diagnostics showed a Windows system error dialog:
```text
covenant-setup-installer.exe - System Error
The code execution cannot proceed because VCRUNTIME140.dll was not found.
```
Evidence:
- `interactive-windows.json` showed a `covenant-setup-installer.exe - System Error` window.
- `system-events.json` had an `Application Popup` event for the missing DLL.
- There were no `installer-heartbeat-*.jsonl` files.
- There were no `csharp-ui-pipe-*.jsonl` files.
Conclusion:
The executable failed before Rust `main()` ran. The heartbeat and pipe logs were absent because neither Rust nor the C# UI started.
Fix:
Added `.cargo/config.toml`:
```toml
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
```
This statically links the MSVC C runtime into the Rust executable, removing the guest dependency on `VCRUNTIME140.dll`.
### 3. Packaging Looked Successful but Produced an Unbundled EXE
Manual PowerShell invocations of the Windows-subsystem Rust executable were misleading. A direct command such as:
```powershell
target\release\covenant-setup.exe --json package vm\self-test\install.toml --output dist\vagrant-self-test
```
could return quickly with `EXIT=0` while the output file still matched the base executable size.
Reason:
The Rust binary is built as a Windows GUI subsystem executable. Direct invocation from PowerShell does not behave like a normal console command in all cases.
Fixes:
- The smoke harness invokes the packager with `Start-Process -Wait -PassThru`.
- The harness now validates that the packaged installer ends with the bundle magic marker `COVENANT_SETUP_BUNDLE_V1`.
- The embedded bundle format was changed from JSON byte arrays to an appended raw bundle with a JSON index plus raw file data. This avoids large JSON expansion of payload bytes.
### 4. Journal Written Beside the Smoke Installer
After the VCRUNTIME fix, the installer succeeded but the smoke verifier failed with:
```text
Journal missing: C:\Users\vagrant\AppData\Local\CovenantSetupSelfTest\journal.json
```
Evidence:
The Rust heartbeat showed:
```json
{"phase":"install_journal_written","detail":{"journal":"C:\\Users\\vagrant\\AppData\\Local\\Temp\\covenant-setup-smoke\\journal.json"}}
```
Cause:
The packaged install path was passing an explicit journal path next to the packaged installer. The expected product behavior is to infer the install root and write `journal.json` there.
Fix:
`run_bundled_installer` now calls:
```rust
install(&manifest_path, None, true, ui_mode, logger)
```
This lets `build_install_runtime` infer the journal path from the install root.
### 5. Scheduled Task Re-ran During Diagnostics
The guest harness originally registered a scheduled task with a trigger one minute in the future and also started it manually.
Failure mode:
- The manual task run completed.
- If verification or diagnostics took long enough, the scheduled trigger fired and launched a second copy.
Fix:
The trigger is now set far in the future:
```powershell
New-ScheduledTaskTrigger -Once -At (Get-Date).AddDays(1)
```
The harness still starts the task manually with `Start-ScheduledTask`.
### 6. WinRM Error 1726 During Success Diagnostics
After the installer succeeded, the host sometimes saw:
```text
WSMAN ERROR CODE: 1726
The WSMan provider host process did not return a proper response.
```
The trace showed the installer succeeded and verification reached the success diagnostics phase, but the WinRM command failed while returning.
Fix:
- The success path now writes `guest-result.json` immediately after verification.
- Heavy diagnostics are retained for failure paths.
- Diagnostic file writes now emit `diagnostic_file_start`, `diagnostic_file_finish`, and `diagnostic_file_error` markers so future diagnostic hangs show the exact capture that blocked.
### 7. Install-plus-uninstall Harness Hung
When uninstall testing was added, the install scheduled task exited with `LastTaskResult=1`. The parent loop waited until timeout because no result file was written.
Evidence:
- `guest-events.jsonl` showed the install scheduled task was registered and started.
- The task quickly moved to `Ready` with `LastTaskResult=1`.
- There was no `interactive_installer_start`.
- There was no Rust heartbeat.
- There was no C# pipe log.
This meant the PowerShell wrapper failed before starting the installer.
Reproduction:
A harmless wrapper test failed:
```powershell
Invoke-InteractiveInstaller.ps1 `
-InstallerPath C:\Windows\System32\cmd.exe `
-InstallerArguments "/c" "exit 0"
```
Error:
```text
A positional parameter cannot be found that accepts argument 'exit 0'.
```
Cause:
Under `powershell.exe -File`, passing multiple values to a script `[string[]]` parameter was not binding as intended.
Fix:
The task wrapper now passes child process arguments as base64-encoded JSON:
```powershell
$argumentsJson = ConvertTo-Json -InputObject $Arguments -Compress
$argumentsBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($argumentsJson))
```
The interactive wrapper decodes that back to a real argument array:
```powershell
$argumentsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($InstallerArgumentsBase64))
$decodedArguments = ConvertFrom-Json -InputObject $argumentsJson
$InstallerArguments = @()
foreach ($argument in $decodedArguments) {
$InstallerArguments += [string]$argument
}
```
The harmless wrapper test then produced:
```json
"installerArgs": ["/c", "exit 0"],
"exitCode": 0
```
## Uninstall Test Flow
The smoke harness now does this inside the guest:
1. Schedules an interactive install task.
2. Runs the packaged installer with:
```text
--headed --automation
```
3. Verifies installed state:
- `%LOCALAPPDATA%\CovenantSetupSelfTest\bin\covenant-setup.exe`
- `%LOCALAPPDATA%\CovenantSetupSelfTest\journal.json`
- `%LOCALAPPDATA%\CovenantSetupSelfTest\covenant-setup-uninstall.exe`
- `HKCU:\Software\CovenantSetupSelfTest`
- `Desktop\Covenant Setup Self Test.lnk`
4. Schedules an interactive uninstall task.
5. Runs the installed uninstaller with:
```text
--headed --automation uninstall <journal path>
```
6. Waits for cleanup helper completion.
7. Verifies removed state:
- install root removed
- payload removed
- journal removed
- installed uninstaller removed
- desktop shortcut removed
- application registry key removed
- Installed Apps uninstall registration removed
## Automation Changes for Uninstall
The uninstall path can spawn a cleanup helper to delete the running uninstaller executable after the main uninstall process exits. The cleanup helper previously could still show a GUI success or reboot prompt.
Fix:
- `uninstall` now receives the automation flag.
- `cleanup` now receives the automation flag.
- `spawn_cleanup_helper` propagates `--automation`.
- When the parent UI mode is GUI, `spawn_cleanup_helper` also passes `--headed`.
- GUI success/reboot prompts are skipped in automation mode.
This keeps the C# progress UI visible while preventing blocking prompts during automated tests.
## Abort Collector
Added:
```text
scripts/windows-vm/Abort-SmokeDiagnostics.ps1
```
Purpose:
- Write an explicit `abort_requested` event.
- Capture processes, visible windows, scheduled tasks, task info, and recent event logs.
- Stop installer, uninstaller, C# UI, and smoke scheduled tasks.
- Unregister smoke scheduled tasks.
- Zip and return the trace bundle as base64.
This is useful when the host-side test command is interrupted and the normal `finally` block does not complete.
## Final Verified Result
The final smoke test command was:
```powershell
.\scripts\run-windows-vm-smoke.ps1 -SkipViewer -HaltAfter
```
It passed with:
```json
{
"success": true,
"exitCode": 0,
"installExitCode": 0,
"uninstallExitCode": 0,
"uninstallVerified": true
}
```
The final trace showed both scheduled tasks completing:
- `CovenantSetupSelfInstall-Install-...`
- `CovenantSetupSelfInstall-Uninstall-...`
It also showed:
- install Rust heartbeat
- install C# pipe log
- uninstall Rust heartbeat
- uninstall C# pipe log
- cleanup helper heartbeat
- `uninstall_cleanup_observed` with every checked path/key absent
## Relevant Code Changes
### Build and Packaging
- `.cargo/config.toml`
- Enables static MSVC runtime linking for the Rust executable.
- `build.rs`
- Publishes the C# WinForms UI as self-contained `win-x64`.
- Sets `COVENANT_SETUP_UI_EXE` for Rust embedding.
- `src/main.rs`
- Adds trace events.
- Uses raw embedded bundle format.
- Uses C# UI IPC instead of PowerShell UI.
- Writes packaged install journal to the inferred install root.
- Propagates automation through uninstall cleanup.
### C# UI
- `src/ui.rs`
- Extracts embedded C# UI executable.
- Connects to a named pipe.
- Sends JSON UI messages.
- Logs Rust-side pipe events.
- `ui/Covenant.Setup.Ui/Program.cs`
- Hosts the named pipe server.
- Displays progress, logs, and prompts.
- Logs C# pipe events to `csharp-ui-pipe-<pid>.jsonl`.
### Vagrant Harness
- `scripts/run-windows-vm-smoke.ps1`
- Requires `dotnet`.
- Packages with `Start-Process -Wait`.
- Validates embedded bundle marker.
- Uploads guest scripts.
- Pulls trace bundle in `finally`.
- Reports install and uninstall status.
- `scripts/windows-vm/Start-InteractiveSelfInstall.ps1`
- Runs install and uninstall as separate interactive scheduled tasks.
- Verifies installed state before uninstall.
- Verifies removed state after uninstall.
- Writes detailed guest trace events.
- `scripts/windows-vm/Invoke-InteractiveInstaller.ps1`
- Starts a target executable with decoded argument list.
- Polls process state instead of relying only on `WaitForExit`.
- Writes per-operation diagnostics.
- `scripts/windows-vm/Abort-SmokeDiagnostics.ps1`
- Captures and aborts an in-progress smoke run.
### Removed PowerShell UI
- `scripts/windows-vm/Approve-InstallerDialogs.ps1`
- Removed because the automation path no longer clicks PowerShell UI dialogs.
- `src/win.rs`
- PowerShell/TaskDialog UI helpers were removed from the primary UI flow.
## Troubleshooting Guide for the Next Hang
1. Check whether the host command is still running:
```powershell
Get-Process | Where-Object { $_.ProcessName -match 'vagrant|ruby|covenant' }
```
2. If the host-side Vagrant process is stuck and the run should be aborted, stop only the Vagrant/Ruby processes for that run.
3. Pull guest diagnostics:
```powershell
vagrant upload scripts\windows-vm\Abort-SmokeDiagnostics.ps1 C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\scripts\Abort-SmokeDiagnostics.ps1
vagrant winrm -s powershell -c "& 'C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\scripts\Abort-SmokeDiagnostics.ps1'"
```
4. Inspect `dist\vagrant-self-test\trace\guest-events.jsonl`.
5. Interpret missing logs:
- No `interactive_installer_start`: scheduled task or PowerShell wrapper failed before launching the installer.
- `interactive_installer_start` exists, but no `installer-heartbeat-*.jsonl`: executable failed before Rust `main()`, usually loader/dependency/signing/OS error.
- Rust heartbeat exists, but no `csharp-ui-pipe-*.jsonl`: C# UI failed to start or pipe connection failed.
- Both heartbeat and pipe logs exist: inspect the last Rust phase and last C# pipe phase to find the blocked operation.
6. Check `interactive-windows.json` for modal system dialogs.
7. Check `abort-processes.json` and `abort-scheduled-task-info.json` for orphaned tasks or running installers.
+42
View File
@@ -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
+35
View File
@@ -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"]
+3
View File
@@ -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")
+3
View File
@@ -0,0 +1,3 @@
@echo off
echo GlassBox sample app executed.
pause
+55
View File
@@ -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., `covenant-setup install manifest.toml`, `covenant-setup uninstall journal.json`).
* **Structured Output Protocol:** The engine must accept a `--json` flag. When active, all standard text logs, progress percentages, and error stack traces must be suppressed and replaced with single-line serialized JSON objects emitted to `stdout`.
copilot-pull-request-reviewer[bot] commented 2026-04-29 01:36:53 +00:00 (Migrated from github.com)
Review

This doc uses glassbox as the example CLI name, but the actual tool/repo is covenant-setup (and the rest of the docs/examples use that). Consider updating the command examples here to match the real binary name to avoid confusion for readers.

This doc uses `glassbox` as the example CLI name, but the actual tool/repo is `covenant-setup` (and the rest of the docs/examples use that). Consider updating the command examples here to match the real binary name to avoid confusion for readers.
* **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)
+210
View File
@@ -0,0 +1,210 @@
[CmdletBinding()]
param(
[string]$Provider = "hyperv",
[string[]]$Scenarios = @("self-test", "uac", "hklm-registry", "reboot", "bundled-exec"),
[string]$VmName = $(if ($env:COVENANT_VM_NAME) { $env:COVENANT_VM_NAME } else { "covenant-setup-windows" }),
[string]$GuestUsername = $(if ($env:COVENANT_WINRM_USERNAME) { $env:COVENANT_WINRM_USERNAME } else { "vagrant" }),
[string]$GuestPassword = $(if ($env:COVENANT_WINRM_PASSWORD) { $env:COVENANT_WINRM_PASSWORD } else { "vagrant" }),
[switch]$SkipBuild,
[switch]$SkipVmBoot,
[switch]$SkipViewer,
[switch]$HaltAfter,
[switch]$DestroyAfter
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Coverage-harness orchestrator. Drives the Vagrant Windows VM through every
# scenario directory under vm\<scenario>\install.toml using the per-scenario
# guest scripts under scripts\windows-vm\coverage\<scenario>.ps1.
#
# All install/uninstall side effects happen INSIDE the VM. The host only:
# 1. Builds covenant-setup.exe (release).
# 2. Stages payload trees per scenario (where the manifest references
# payload\covenant-setup.exe).
# 3. Boots the VM, uploads the exe + scenarios + guest scripts.
# 4. WinRM-invokes each guest script and aggregates results.
#
# Existing scripts under scripts\windows-vm\coverage\*.ps1 are guest-side and
# already accept -Exe -Manifest -WorkRoot.
function Assert-Command {
param([Parameter(Mandatory)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required command not found on PATH: $Name"
}
}
function Invoke-Tool {
param(
[Parameter(Mandatory)][string]$FilePath,
[string[]]$Arguments = @()
)
Write-Host "==> $FilePath $($Arguments -join ' ')"
Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
& $FilePath @Arguments
$exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
$exitCode = if ($exitCodeVar) { [int]$exitCodeVar.Value } elseif ($?) { 0 } else { 1 }
if ($exitCode -ne 0) {
throw "Command failed with exit code ${exitCode}: $FilePath $($Arguments -join ' ')"
}
}
function Invoke-Vagrant {
param([string[]]$Arguments)
Invoke-Tool -FilePath "vagrant" -Arguments $Arguments
}
function Invoke-VagrantOutput {
param([string[]]$Arguments)
Write-Host "==> vagrant $($Arguments -join ' ')"
Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
$output = & vagrant @Arguments 2>&1
$exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
$exitCode = if ($exitCodeVar) { [int]$exitCodeVar.Value } elseif ($?) { 0 } else { 1 }
return [pscustomobject]@{ Output = ($output | Out-String); ExitCode = $exitCode }
}
function Open-HyperVViewer {
param([Parameter(Mandatory)][string]$VmName)
$vmConnect = Join-Path $env:SystemRoot "System32\vmconnect.exe"
if (-not (Test-Path -LiteralPath $vmConnect)) { return }
Start-Process -FilePath $vmConnect -ArgumentList @("localhost", $VmName) | Out-Null
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$releaseExe = Join-Path $repoRoot "target\release\covenant-setup.exe"
$outputRoot = Join-Path $repoRoot "dist\vagrant-coverage"
$summaryPath = Join-Path $outputRoot "summary.json"
$guestRoot = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-coverage"
$guestExe = Join-Path $guestRoot "bin\covenant-setup.exe"
$guestScriptRoot = Join-Path $guestRoot "scripts"
$guestScenarioRoot = Join-Path $guestRoot "scenarios"
$guestWorkRoot = Join-Path $guestRoot "work"
Assert-Command -Name "cargo"
Assert-Command -Name "vagrant"
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
if (-not $SkipBuild) {
Invoke-Tool -FilePath "cargo" -Arguments @("build", "--release")
}
if (-not (Test-Path -LiteralPath $releaseExe)) {
throw "Release binary not found at $releaseExe"
}
# Stage the payload tree for each scenario manifest that references
# payload\covenant-setup.exe (relative to the manifest dir).
foreach ($scenario in $Scenarios) {
$manifest = Join-Path $repoRoot "vm\$scenario\install.toml"
if (-not (Test-Path -LiteralPath $manifest)) {
throw "Scenario manifest not found: $manifest"
}
$payloadDir = Join-Path $repoRoot "vm\$scenario\payload"
$manifestText = Get-Content -LiteralPath $manifest -Raw
if ($manifestText -match 'payload\\\\covenant-setup\.exe' -or $manifestText -match 'payload[\\/]covenant-setup\.exe') {
New-Item -ItemType Directory -Force -Path $payloadDir | Out-Null
Copy-Item -LiteralPath $releaseExe -Destination (Join-Path $payloadDir "covenant-setup.exe") -Force
}
}
$results = @()
$hadFailure = $false
try {
if (-not $SkipVmBoot) {
Invoke-Vagrant -Arguments @("up", "--provider", $Provider)
}
if ($Provider -ieq "hyperv" -and -not $SkipViewer) {
Open-HyperVViewer -VmName $VmName
}
$waitForShellCommand = "for (`$i = 0; `$i -lt 90; `$i++) { if (Get-Process -Name explorer -ErrorAction SilentlyContinue) { exit 0 }; Start-Sleep -Seconds 2 }; Write-Error 'Explorer shell did not start in time.'; exit 1"
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $waitForShellCommand)
# Prepare guest layout.
$prepCommand = @(
"New-Item -ItemType Directory -Force -Path '$guestRoot' | Out-Null"
"New-Item -ItemType Directory -Force -Path '$(Join-Path $guestRoot 'bin')' | Out-Null"
"New-Item -ItemType Directory -Force -Path '$guestScriptRoot' | Out-Null"
"New-Item -ItemType Directory -Force -Path '$guestScenarioRoot' | Out-Null"
"New-Item -ItemType Directory -Force -Path '$guestWorkRoot' | Out-Null"
) -join "; "
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $prepCommand)
# Upload covenant-setup.exe and per-scenario assets.
Invoke-Vagrant -Arguments @("upload", $releaseExe, $guestExe)
foreach ($scenario in $Scenarios) {
$localScenarioDir = Join-Path $repoRoot "vm\$scenario"
$remoteScenarioDir = Join-Path $guestScenarioRoot $scenario
Invoke-Vagrant -Arguments @("upload", $localScenarioDir, $remoteScenarioDir)
$localScript = Join-Path $repoRoot "scripts\windows-vm\coverage\$scenario.ps1"
if (-not (Test-Path -LiteralPath $localScript)) {
throw "Scenario script not found: $localScript"
}
$remoteScript = Join-Path $guestScriptRoot "$scenario.ps1"
Invoke-Vagrant -Arguments @("upload", $localScript, $remoteScript)
}
# Run each scenario in the guest. Capture exit code and stderr/stdout
# without throwing so we can record per-scenario status.
foreach ($scenario in $Scenarios) {
Write-Host ""
Write-Host "[coverage] -> $scenario" -ForegroundColor Cyan
$remoteScript = Join-Path $guestScriptRoot "$scenario.ps1"
$remoteManifest = Join-Path $guestScenarioRoot "$scenario\install.toml"
$remoteWork = Join-Path $guestWorkRoot $scenario
$logRel = "$scenario\guest.log"
$remoteLog = Join-Path $guestWorkRoot $logRel
$invokeCommand = @(
"New-Item -ItemType Directory -Force -Path '$remoteWork' | Out-Null"
"& '$remoteScript' -Exe '$guestExe' -Manifest '$remoteManifest' -WorkRoot '$remoteWork' *> '$remoteLog'"
"exit `$LASTEXITCODE"
) -join "; "
$invocation = Invoke-VagrantOutput -Arguments @("winrm", "-s", "powershell", "-c", $invokeCommand)
$localScenarioOut = Join-Path $outputRoot $scenario
New-Item -ItemType Directory -Force -Path $localScenarioOut | Out-Null
# Pull the guest log.
$logFetch = Invoke-VagrantOutput -Arguments @("winrm", "-s", "powershell", "-c", "if (Test-Path -LiteralPath '$remoteLog') { Get-Content -LiteralPath '$remoteLog' -Raw } else { '__COVENANT_NO_LOG__' }")
Set-Content -LiteralPath (Join-Path $localScenarioOut "guest.log") -Value $logFetch.Output -Encoding UTF8
$success = ($invocation.ExitCode -eq 0)
$results += [pscustomobject]@{ scenario = $scenario; success = $success; exitCode = $invocation.ExitCode }
if (-not $success) {
$hadFailure = $true
Write-Host "[coverage] $scenario FAILED (exit $($invocation.ExitCode))" -ForegroundColor Red
Write-Host $invocation.Output
} else {
Write-Host "[coverage] $scenario OK" -ForegroundColor Green
}
}
}
finally {
$summary = [pscustomobject]@{
scenarios = $results
success = -not $hadFailure
}
$summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $summaryPath -Encoding UTF8
Write-Host ""
Write-Host "Summary: $summaryPath"
foreach ($r in $results) {
$color = if ($r.success) { "Green" } else { "Red" }
Write-Host (" {0,-18} success={1} exit={2}" -f $r.scenario, $r.success, $r.exitCode) -ForegroundColor $color
}
if ($DestroyAfter) {
try { Invoke-Vagrant -Arguments @("destroy", "-f") } catch { Write-Warning $_.Exception.Message }
} elseif ($HaltAfter) {
try { Invoke-Vagrant -Arguments @("halt") } catch { Write-Warning $_.Exception.Message }
}
}
if ($hadFailure) {
throw "One or more coverage scenarios failed. See $summaryPath."
}
+304
View File
@@ -0,0 +1,304 @@
[CmdletBinding()]
param(
[string]$Provider = "hyperv",
[string]$ManifestPath = "vm\self-test\install.toml",
[string]$OutputRoot = "dist\vagrant-self-test",
[string]$VmName = $(if ($env:COVENANT_VM_NAME) { $env:COVENANT_VM_NAME } else { "covenant-setup-windows" }),
[string]$GuestUsername = $(if ($env:COVENANT_WINRM_USERNAME) { $env:COVENANT_WINRM_USERNAME } else { "vagrant" }),
[string]$GuestPassword = $(if ($env:COVENANT_WINRM_PASSWORD) { $env:COVENANT_WINRM_PASSWORD } else { "vagrant" }),
[switch]$SkipBuild,
[switch]$SkipVmBoot,
[switch]$SkipViewer,
[switch]$HaltAfter,
[switch]$DestroyAfter
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Assert-Command {
param([Parameter(Mandatory)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required command not found on PATH: $Name"
}
}
function Resolve-RepoPath {
param(
[Parameter(Mandatory)][string]$RepoRoot,
[Parameter(Mandatory)][string]$Path
)
if ([System.IO.Path]::IsPathRooted($Path)) {
return $Path
}
return Join-Path $RepoRoot $Path
}
function Convert-ToSingleQuotedPowerShellLiteral {
param([Parameter(Mandatory)][string]$Value)
return "'" + $Value.Replace("'", "''") + "'"
}
function Invoke-Tool {
param(
[Parameter(Mandatory)][string]$FilePath,
[string[]]$Arguments = @()
)
Write-Host "==> $FilePath $($Arguments -join ' ')"
Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
& $FilePath @Arguments
$exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
$exitCode = if ($exitCodeVar) { [int]$exitCodeVar.Value } elseif ($?) { 0 } else { 1 }
if ($exitCode -ne 0) {
throw "Command failed with exit code ${exitCode}: $FilePath $($Arguments -join ' ')"
}
}
function Invoke-Vagrant {
param([string[]]$Arguments)
Invoke-Tool -FilePath "vagrant" -Arguments $Arguments
}
function Save-GuestTraceBundle {
param(
[Parameter(Mandatory)][string]$GuestTracePath,
[Parameter(Mandatory)][string]$GuestZipPath,
[Parameter(Mandatory)][string]$LocalTracePath
)
try {
New-Item -ItemType Directory -Force -Path $LocalTracePath | Out-Null
$guestTraceLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestTracePath
$guestZipLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestZipPath
$command = @(
"`$tracePath = $guestTraceLiteral"
"`$zipPath = $guestZipLiteral"
"if (-not (Test-Path -LiteralPath `$tracePath)) { Write-Output '__COVENANT_TRACE_EMPTY__'; exit 0 }"
"New-Item -ItemType File -Force -Path (Join-Path `$tracePath '.keep') | Out-Null"
"Remove-Item -LiteralPath `$zipPath -Force -ErrorAction SilentlyContinue"
"Compress-Archive -Path (Join-Path `$tracePath '*') -DestinationPath `$zipPath -Force"
"Write-Output '__COVENANT_TRACE_B64_START__'"
"[Convert]::ToBase64String([IO.File]::ReadAllBytes(`$zipPath))"
"Write-Output '__COVENANT_TRACE_B64_END__'"
) -join "; "
$output = Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $command)
$lines = @($output | ForEach-Object { $_.ToString().Trim() })
if ($lines -contains "__COVENANT_TRACE_EMPTY__") {
Write-Warning "Guest trace path did not exist: $GuestTracePath"
return
}
$start = [Array]::IndexOf($lines, "__COVENANT_TRACE_B64_START__")
$end = [Array]::IndexOf($lines, "__COVENANT_TRACE_B64_END__")
if ($start -lt 0 -or $end -le $start) {
Write-Warning "Guest trace bundle markers were not found in WinRM output."
return
}
$base64 = (($lines[($start + 1)..($end - 1)]) -join "").Trim()
if (-not $base64) {
Write-Warning "Guest trace bundle was empty."
return
}
$zipPath = Join-Path $LocalTracePath "guest-trace.zip"
[IO.File]::WriteAllBytes($zipPath, [Convert]::FromBase64String($base64))
Expand-Archive -LiteralPath $zipPath -DestinationPath $LocalTracePath -Force
Write-Host "Trace bundle: $LocalTracePath"
}
catch {
Write-Warning "Failed to collect guest trace bundle: $($_.Exception.Message)"
}
}
function Open-HyperVViewer {
param([Parameter(Mandatory)][string]$VmName)
$vmConnect = Join-Path $env:SystemRoot "System32\vmconnect.exe"
if (-not (Test-Path -LiteralPath $vmConnect)) {
Write-Warning "Hyper-V viewer not found at $vmConnect"
return
}
Write-Host "==> $vmConnect localhost $VmName"
Start-Process -FilePath $vmConnect -ArgumentList @("localhost", $VmName) | Out-Null
}
function Invoke-Process {
param(
[Parameter(Mandatory)][string]$FilePath,
[string[]]$Arguments = @()
)
Write-Host "==> $FilePath $($Arguments -join ' ')"
$process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru
if ($process.ExitCode -ne 0) {
throw "Command failed with exit code $($process.ExitCode): $FilePath $($Arguments -join ' ')"
}
}
function Assert-PackagedInstallerBundle {
param([Parameter(Mandatory)][string]$InstallerPath)
$magic = [Text.Encoding]::ASCII.GetBytes("COVENANT_SETUP_BUNDLE_V1")
$stream = [IO.File]::Open(
$InstallerPath,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::ReadWrite)
try {
if ($stream.Length -lt $magic.Length) {
throw "Packaged installer is too small to contain the embedded bundle marker: $InstallerPath"
}
$null = $stream.Seek(-1 * $magic.Length, [IO.SeekOrigin]::End)
$actual = [byte[]]::new($magic.Length)
$read = $stream.Read($actual, 0, $actual.Length)
if ($read -ne $magic.Length) {
throw "Could not read embedded bundle marker from packaged installer: $InstallerPath"
}
for ($i = 0; $i -lt $magic.Length; $i++) {
if ($actual[$i] -ne $magic[$i]) {
throw "Packaged installer is missing the embedded bundle marker: $InstallerPath"
}
}
}
finally {
$stream.Dispose()
}
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$releaseExe = Join-Path $repoRoot "target\release\covenant-setup.exe"
$payloadRoot = Join-Path $repoRoot "vm\self-test\payload"
$stagedPayload = Join-Path $payloadRoot "covenant-setup.exe"
$manifestPathAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $ManifestPath
$outputRootAbs = Resolve-RepoPath -RepoRoot $repoRoot -Path $OutputRoot
$installerPath = Join-Path $outputRootAbs "covenant-setup-installer.exe"
$resultPath = Join-Path $outputRootAbs "guest-result.json"
$traceRootAbs = Join-Path $outputRootAbs "trace"
$guestRoot = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke"
$guestInstallerPath = Join-Path $guestRoot "covenant-setup-installer.exe"
$guestResultPath = Join-Path $guestRoot "guest-result.json"
$guestTraceRoot = Join-Path $guestRoot "trace"
$guestTraceZipPath = Join-Path $guestRoot "trace.zip"
$guestScriptRoot = Join-Path $guestRoot "scripts"
$guestCommand = "& '$guestScriptRoot\Start-InteractiveSelfInstall.ps1' -InstallerPath '$guestInstallerPath' -ResultPath '$guestResultPath' -ScriptRoot '$guestScriptRoot' -TracePath '$guestTraceRoot'"
Assert-Command -Name "cargo"
Assert-Command -Name "dotnet"
Assert-Command -Name "vagrant"
New-Item -ItemType Directory -Force -Path $payloadRoot | Out-Null
New-Item -ItemType Directory -Force -Path $outputRootAbs | Out-Null
Remove-Item -LiteralPath $resultPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $traceRootAbs -Recurse -Force -ErrorAction SilentlyContinue
try {
if (-not $SkipBuild) {
Invoke-Tool -FilePath "cargo" -Arguments @("build", "--release")
}
if (-not (Test-Path -LiteralPath $releaseExe)) {
throw "Release binary not found at $releaseExe"
}
if (-not (Test-Path -LiteralPath $manifestPathAbs)) {
throw "Self-test manifest not found at $manifestPathAbs"
}
Copy-Item -LiteralPath $releaseExe -Destination $stagedPayload -Force
Invoke-Process -FilePath $releaseExe -Arguments @("package", $manifestPathAbs, "--output", $outputRootAbs)
if (-not (Test-Path -LiteralPath $installerPath)) {
throw "Packaged installer not found at $installerPath"
}
Assert-PackagedInstallerBundle -InstallerPath $installerPath
if (-not $SkipVmBoot) {
Invoke-Vagrant -Arguments @("up", "--provider", $Provider)
$guestUsernameLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestUsername
$guestPasswordLiteral = Convert-ToSingleQuotedPowerShellLiteral -Value $GuestPassword
$enableAutoLogonCommand = @(
'$winlogon = ''HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'''
"New-ItemProperty -Path `$winlogon -Name 'AutoAdminLogon' -PropertyType String -Value '1' -Force | Out-Null"
"New-ItemProperty -Path `$winlogon -Name 'ForceAutoLogon' -PropertyType String -Value '1' -Force | Out-Null"
"New-ItemProperty -Path `$winlogon -Name 'DefaultUserName' -PropertyType String -Value $guestUsernameLiteral -Force | Out-Null"
"New-ItemProperty -Path `$winlogon -Name 'DefaultPassword' -PropertyType String -Value $guestPasswordLiteral -Force | Out-Null"
"New-ItemProperty -Path `$winlogon -Name 'DefaultDomainName' -PropertyType String -Value `$env:COMPUTERNAME -Force | Out-Null"
) -join "; "
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $enableAutoLogonCommand)
Invoke-Vagrant -Arguments @("reload")
}
if ($Provider -ieq "hyperv" -and -not $SkipViewer) {
Open-HyperVViewer -VmName $VmName
}
$waitForShellCommand = "for (`$i = 0; `$i -lt 90; `$i++) { if (Get-Process -Name explorer -ErrorAction SilentlyContinue) { exit 0 }; Start-Sleep -Seconds 2 }; Write-Error 'Explorer shell did not start in time.'; exit 1"
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $waitForShellCommand)
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", "New-Item -ItemType Directory -Force -Path '$guestRoot' | Out-Null; New-Item -ItemType Directory -Force -Path '$guestScriptRoot' | Out-Null")
$clearGuestTraceCommand = @(
"try {"
"if (Test-Path -LiteralPath '$guestTraceRoot') { Remove-Item -LiteralPath '$guestTraceRoot' -Recurse -Force -ErrorAction SilentlyContinue }"
"if (Test-Path -LiteralPath '$guestTraceZipPath') { Remove-Item -LiteralPath '$guestTraceZipPath' -Force -ErrorAction SilentlyContinue }"
"} catch { Write-Warning `$_.Exception.Message }"
"exit 0"
) -join "; "
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $clearGuestTraceCommand)
Invoke-Vagrant -Arguments @("upload", $installerPath, $guestInstallerPath)
Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Invoke-InteractiveInstaller.ps1"), (Join-Path $guestScriptRoot "Invoke-InteractiveInstaller.ps1"))
Invoke-Vagrant -Arguments @("upload", (Join-Path $repoRoot "scripts\windows-vm\Start-InteractiveSelfInstall.ps1"), (Join-Path $guestScriptRoot "Start-InteractiveSelfInstall.ps1"))
Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $guestCommand)
$resultJson = Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", "Get-Content -LiteralPath '$guestResultPath' -Raw")
Set-Content -LiteralPath $resultPath -Value $resultJson -Encoding UTF8
if (-not (Test-Path -LiteralPath $resultPath)) {
throw "Guest smoke-test result file was not written: $resultPath"
}
$result = $resultJson | ConvertFrom-Json
if (-not $result.success) {
throw "Guest reported a failed smoke test: $($result.error)"
}
Write-Host ""
Write-Host "Smoke test passed."
Write-Host "Installer: $installerPath"
Write-Host "Result JSON: $resultPath"
Write-Host "Trace: $traceRootAbs"
Write-Host "InstallRoot: $($result.installRoot) (installed, then removed)"
Write-Host "Uninstall: exit $($result.uninstallExitCode), verified=$($result.uninstallVerified)"
}
finally {
Save-GuestTraceBundle -GuestTracePath $guestTraceRoot -GuestZipPath $guestTraceZipPath -LocalTracePath $traceRootAbs
if ($DestroyAfter) {
try {
Invoke-Vagrant -Arguments @("destroy", "-f")
}
catch {
Write-Warning "Failed to destroy VM after smoke test: $($_.Exception.Message)"
}
}
elseif ($HaltAfter) {
try {
Invoke-Vagrant -Arguments @("halt")
}
catch {
Write-Warning "Failed to halt VM after smoke test: $($_.Exception.Message)"
}
}
}
@@ -0,0 +1,115 @@
param(
[string]$TracePath = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace",
[string]$ZipPath = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-smoke\trace-abort.zip",
[string]$TaskNamePrefix = "CovenantSetupSelfInstall"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Continue"
function Write-TraceEvent {
param(
[Parameter(Mandatory)][string]$Phase,
[object]$Detail = $null
)
try {
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
$event = [ordered]@{
time = (Get-Date).ToUniversalTime().ToString("o")
pid = $PID
script = Split-Path -Leaf $PSCommandPath
phase = $Phase
detail = $Detail
}
$event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8
}
catch {
Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)"
}
}
function Write-DiagnosticFile {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][scriptblock]$Capture
)
$path = Join-Path $TracePath $Name
Write-TraceEvent -Phase "abort_diagnostic_file_start" -Detail @{ name = $Name }
try {
$value = & $Capture
$value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8
Write-TraceEvent -Phase "abort_diagnostic_file_finish" -Detail @{ name = $Name }
}
catch {
[ordered]@{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8
Write-TraceEvent -Phase "abort_diagnostic_file_error" -Detail @{
name = $Name
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
}
}
}
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
Write-TraceEvent -Phase "abort_requested"
Write-DiagnosticFile -Name "abort-processes.json" -Capture {
Get-CimInstance Win32_Process |
Where-Object {
$_.Name -match "covenant|Covenant|powershell|pwsh" -or
$_.CommandLine -match "CovenantSetup|Invoke-InteractiveInstaller|Start-InteractiveSelfInstall"
} |
Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate
}
Write-DiagnosticFile -Name "abort-windows.json" -Capture {
Get-Process |
Where-Object { $_.MainWindowHandle -ne 0 -or $_.ProcessName -match "covenant|Covenant|powershell|pwsh" } |
Select-Object Id, ProcessName, MainWindowTitle, MainWindowHandle, StartTime
}
Write-DiagnosticFile -Name "abort-scheduled-tasks.json" -Capture {
Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue |
Select-Object TaskName, State, TaskPath, Actions, Triggers
}
Write-DiagnosticFile -Name "abort-scheduled-task-info.json" -Capture {
Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue |
ForEach-Object { Get-ScheduledTaskInfo -TaskName $_.TaskName -ErrorAction SilentlyContinue } |
Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns
}
Write-DiagnosticFile -Name "abort-application-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-DiagnosticFile -Name "abort-system-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "System"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Get-Process -Name "covenant-setup-installer", "covenant-setup", "covenant-setup-uninstall", "Covenant.Setup.Ui" -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue |
Stop-ScheduledTask -ErrorAction SilentlyContinue
Get-ScheduledTask -TaskName "$TaskNamePrefix*" -ErrorAction SilentlyContinue |
Unregister-ScheduledTask -Confirm:$false -ErrorAction SilentlyContinue
$scriptProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$_.ProcessId -ne $PID -and
$_.CommandLine -match "Invoke-InteractiveInstaller|Start-InteractiveSelfInstall"
}
foreach ($process in $scriptProcesses) {
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
}
Write-TraceEvent -Phase "abort_cleanup_complete"
Remove-Item -LiteralPath $ZipPath -Force -ErrorAction SilentlyContinue
Compress-Archive -Path (Join-Path $TracePath "*") -DestinationPath $ZipPath -Force
Write-Output "__COVENANT_TRACE_B64_START__"
[Convert]::ToBase64String([IO.File]::ReadAllBytes($ZipPath))
Write-Output "__COVENANT_TRACE_B64_END__"
@@ -0,0 +1,194 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$InstallerPath,
[Parameter(Mandatory)][string]$ResultPath,
[int]$TimeoutSeconds = 600,
[string]$TracePath = $(Join-Path (Split-Path -Parent $ResultPath) "trace"),
[string]$OperationName = "installer",
[string]$InstallerArgumentsBase64 = "",
[string[]]$InstallerArguments = @("--headed", "--automation")
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Write-TraceEvent {
param(
[Parameter(Mandatory)][string]$Phase,
[object]$Detail = $null
)
try {
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
$event = [ordered]@{
time = (Get-Date).ToUniversalTime().ToString("o")
pid = $PID
script = Split-Path -Leaf $PSCommandPath
phase = $Phase
detail = $Detail
}
$event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8
}
catch {
Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)"
}
}
function Write-DiagnosticFile {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][scriptblock]$Capture
)
$path = Join-Path $TracePath $Name
try {
$value = & $Capture
$value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8
}
catch {
[ordered]@{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8
}
}
function Export-InstallerDiagnostics {
param(
[Parameter(Mandatory)][string]$Reason,
[System.Diagnostics.Process]$InstallerProcess = $null
)
Write-TraceEvent -Phase "installer_diagnostics_start" -Detail @{ reason = $Reason; installerPid = $(if ($InstallerProcess) { $InstallerProcess.Id } else { $null }) }
Write-DiagnosticFile -Name "interactive-context.json" -Capture {
[ordered]@{
reason = $Reason
computerName = $env:COMPUTERNAME
userName = $env:USERNAME
sessionName = $env:SESSIONNAME
installerPath = $InstallerPath
installerArgs = $InstallerArguments
operationName = $OperationName
resultPath = $ResultPath
tracePath = $TracePath
timeoutSeconds = $TimeoutSeconds
installerPid = $(if ($InstallerProcess) { $InstallerProcess.Id } else { $null })
installerExited = $(if ($InstallerProcess) { $InstallerProcess.HasExited } else { $null })
}
}
Write-DiagnosticFile -Name "interactive-processes.json" -Capture {
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match 'covenant|Covenant|powershell|pwsh|dotnet' } |
Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate
}
Write-DiagnosticFile -Name "interactive-windows.json" -Capture {
Get-Process |
Where-Object { $_.MainWindowHandle -ne 0 -or $_.ProcessName -match 'covenant|Covenant|powershell|pwsh' } |
Select-Object Id, ProcessName, MainWindowTitle, MainWindowHandle, StartTime
}
Write-DiagnosticFile -Name "interactive-application-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-TraceEvent -Phase "installer_diagnostics_finish" -Detail @{ reason = $Reason }
}
$installer = $null
$startedAt = Get-Date
try {
if (-not [string]::IsNullOrWhiteSpace($InstallerArgumentsBase64)) {
$argumentsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($InstallerArgumentsBase64))
$decodedArguments = ConvertFrom-Json -InputObject $argumentsJson
$InstallerArguments = @()
foreach ($argument in $decodedArguments) {
$InstallerArguments += [string]$argument
}
}
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
Write-TraceEvent -Phase "interactive_installer_start" -Detail @{
installerPath = $InstallerPath
installerArgs = $InstallerArguments
operationName = $OperationName
resultPath = $ResultPath
tracePath = $TracePath
timeoutSeconds = $TimeoutSeconds
}
if (-not (Test-Path -LiteralPath $InstallerPath)) {
throw "Installer not found: $InstallerPath"
}
$resultDir = Split-Path -Parent $ResultPath
if ($resultDir) {
New-Item -ItemType Directory -Force -Path $resultDir | Out-Null
}
Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue
$env:COVENANT_SETUP_TRACE_DIR = $TracePath
Write-TraceEvent -Phase "trace_environment_set" -Detail @{ name = "COVENANT_SETUP_TRACE_DIR"; value = $TracePath }
$installer = Start-Process -FilePath $InstallerPath -ArgumentList $InstallerArguments -PassThru
Write-TraceEvent -Phase "installer_process_started" -Detail @{ pid = $installer.Id; operationName = $OperationName }
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$lastPoll = Get-Date "2000-01-01"
while (-not $installer.HasExited) {
if ((Get-Date) -ge $deadline) {
Export-InstallerDiagnostics -Reason "installer_timeout" -InstallerProcess $installer
Stop-Process -Id $installer.Id -Force -ErrorAction SilentlyContinue
throw "Installer timed out after $TimeoutSeconds seconds."
}
if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) {
$lastPoll = Get-Date
$installer.Refresh()
Write-TraceEvent -Phase "installer_still_running" -Detail @{
pid = $installer.Id
operationName = $OperationName
elapsedSeconds = [int]((Get-Date) - $startedAt).TotalSeconds
responding = $installer.Responding
mainWindow = $installer.MainWindowTitle
}
}
Start-Sleep -Seconds 2
$installer.Refresh()
}
Write-TraceEvent -Phase "installer_process_exited" -Detail @{ pid = $installer.Id; exitCode = $installer.ExitCode; operationName = $OperationName }
Export-InstallerDiagnostics -Reason "installer_exit" -InstallerProcess $installer
$result = [ordered]@{
success = ($installer.ExitCode -eq 0)
exitCode = [int]$installer.ExitCode
installerPath = $InstallerPath
installerArgs = $InstallerArguments
operationName = $OperationName
tracePath = $TracePath
startedAt = $startedAt.ToString("o")
finishedAt = (Get-Date).ToString("o")
}
$result | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8
if ($installer.ExitCode -ne 0) {
exit $installer.ExitCode
}
}
catch {
Write-TraceEvent -Phase "interactive_installer_error" -Detail @{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
}
Export-InstallerDiagnostics -Reason "interactive_installer_error" -InstallerProcess $installer
$failure = [ordered]@{
success = $false
error = $_.Exception.Message
tracePath = $TracePath
startedAt = $startedAt.ToString("o")
finishedAt = (Get-Date).ToString("o")
}
$failure | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8
exit 1
}
@@ -0,0 +1,471 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$InstallerPath,
[Parameter(Mandatory)][string]$ResultPath,
[string]$ScriptRoot = $PSScriptRoot,
[int]$TimeoutSeconds = 600,
[string]$TaskNamePrefix = "CovenantSetupSelfInstall",
[string]$TracePath = $(Join-Path (Split-Path -Parent $ResultPath) "trace")
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Quote-TaskArgument {
param([Parameter(Mandatory)][string]$Value)
return '"' + $Value.Replace('"', '""') + '"'
}
function Write-TraceEvent {
param(
[Parameter(Mandatory)][string]$Phase,
[object]$Detail = $null
)
try {
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
$event = [ordered]@{
time = (Get-Date).ToUniversalTime().ToString("o")
pid = $PID
script = Split-Path -Leaf $PSCommandPath
phase = $Phase
detail = $Detail
}
$event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8
}
catch {
Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)"
}
}
function Write-DiagnosticFile {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][scriptblock]$Capture
)
$path = Join-Path $TracePath $Name
Write-TraceEvent -Phase "diagnostic_file_start" -Detail @{ name = $Name }
try {
$value = & $Capture
$value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8
Write-TraceEvent -Phase "diagnostic_file_finish" -Detail @{ name = $Name }
}
catch {
[ordered]@{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
} | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8
Write-TraceEvent -Phase "diagnostic_file_error" -Detail @{
name = $Name
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
}
}
}
function Convert-DateTimeForTrace {
param([object]$Value)
if ($null -eq $Value) {
return $null
}
if ($Value -is [DateTime] -and $Value -eq [DateTime]::MinValue) {
return $null
}
try {
return ([DateTime]$Value).ToString("o")
}
catch {
return [string]$Value
}
}
function Export-SmokeDiagnostics {
param([Parameter(Mandatory)][string]$Reason)
Write-TraceEvent -Phase "diagnostics_start" -Detail @{ reason = $Reason }
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
Write-DiagnosticFile -Name "guest-context.json" -Capture {
[ordered]@{
reason = $Reason
computerName = $env:COMPUTERNAME
userName = $env:USERNAME
installerPath = $InstallerPath
resultPath = $ResultPath
tracePath = $TracePath
taskName = $taskName
installRoot = $installRoot
}
}
Write-DiagnosticFile -Name "processes.json" -Capture {
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match 'covenant|Covenant|powershell|pwsh|dotnet' } |
Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate
}
Write-DiagnosticFile -Name "scheduled-task.json" -Capture {
[ordered]@{
task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue
}
}
Write-DiagnosticFile -Name "scheduled-task-events.json" -Capture {
Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 300 -ErrorAction SilentlyContinue |
Where-Object { $_.Message -like "*$taskName*" } |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-DiagnosticFile -Name "application-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-DiagnosticFile -Name "system-events.json" -Capture {
Get-WinEvent -FilterHashtable @{ LogName = "System"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message
}
Write-DiagnosticFile -Name "install-root-files.json" -Capture {
if (Test-Path -LiteralPath $installRoot) {
Get-ChildItem -LiteralPath $installRoot -Force -Recurse |
Select-Object FullName, Length, LastWriteTimeUtc, Attributes
}
else {
[ordered]@{ exists = $false; path = $installRoot }
}
}
Write-DiagnosticFile -Name "registry-state.json" -Capture {
if (Test-Path -LiteralPath $registryPath) {
Get-ItemProperty -LiteralPath $registryPath
}
else {
[ordered]@{ exists = $false; path = $registryPath }
}
}
Write-DiagnosticFile -Name "result-file.json" -Capture {
if (Test-Path -LiteralPath $ResultPath) {
Get-Content -LiteralPath $ResultPath -Raw
}
else {
[ordered]@{ exists = $false; path = $ResultPath }
}
}
Write-TraceEvent -Phase "diagnostics_finish" -Detail @{ reason = $Reason }
}
function Invoke-InteractiveOperation {
param(
[Parameter(Mandatory)][string]$TaskName,
[Parameter(Mandatory)][string]$ExecutablePath,
[Parameter(Mandatory)][string]$RunResultPath,
[Parameter(Mandatory)][string]$OperationName,
[string[]]$Arguments = @()
)
$script:taskName = $TaskName
Remove-Item -LiteralPath $RunResultPath -Force -ErrorAction SilentlyContinue
# The task is started manually below. Keep the trigger far enough out that it
# cannot fire a second copy while the smoke harness is collecting diagnostics.
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddDays(1)
$actionArgumentItems = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", (Quote-TaskArgument -Value $interactiveScript),
"-InstallerPath", (Quote-TaskArgument -Value $ExecutablePath),
"-ResultPath", (Quote-TaskArgument -Value $RunResultPath),
"-TimeoutSeconds", $TimeoutSeconds,
"-TracePath", (Quote-TaskArgument -Value $TracePath),
"-OperationName", (Quote-TaskArgument -Value $OperationName)
)
if ($Arguments.Count -gt 0) {
$argumentsJson = ConvertTo-Json -InputObject $Arguments -Compress
$argumentsBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($argumentsJson))
$actionArgumentItems += "-InstallerArgumentsBase64"
$actionArgumentItems += $argumentsBase64
}
$actionArguments = $actionArgumentItems -join " "
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArguments
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
try {
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Principal $principal `
-Force | Out-Null
Write-TraceEvent -Phase "scheduled_task_registered" -Detail @{
taskName = $TaskName
operationName = $OperationName
executablePath = $ExecutablePath
executableArgs = $Arguments
actionArguments = $actionArguments
runResultPath = $RunResultPath
}
Start-ScheduledTask -TaskName $TaskName
Write-TraceEvent -Phase "scheduled_task_started" -Detail @{ taskName = $TaskName; operationName = $OperationName }
$deadline = (Get-Date).AddSeconds($TimeoutSeconds + 120)
$lastPoll = Get-Date "2000-01-01"
while ((Get-Date) -lt $deadline) {
if (Test-Path -LiteralPath $RunResultPath) {
Write-TraceEvent -Phase "result_file_observed" -Detail @{
taskName = $TaskName
operationName = $OperationName
runResultPath = $RunResultPath
}
break
}
if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) {
$lastPoll = Get-Date
$taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Write-TraceEvent -Phase "waiting_for_interactive_task" -Detail @{
taskName = $TaskName
operationName = $OperationName
state = $(if ($task) { $task.State.ToString() } else { $null })
lastRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.LastRunTime } else { $null })
lastTaskResult = $(if ($taskInfo) { $taskInfo.LastTaskResult } else { $null })
nextRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.NextRunTime } else { $null })
}
}
Start-Sleep -Seconds 2
}
if (-not (Test-Path -LiteralPath $RunResultPath)) {
Export-SmokeDiagnostics -Reason "${OperationName}_task_timeout"
$taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName
throw "Timed out waiting for $OperationName interactive task. LastTaskResult=$($taskInfo.LastTaskResult)"
}
$runResult = Get-Content -LiteralPath $RunResultPath -Raw | ConvertFrom-Json
Write-TraceEvent -Phase "interactive_task_result_read" -Detail @{
taskName = $TaskName
operationName = $OperationName
result = $runResult
}
if (-not $runResult.success) {
Export-SmokeDiagnostics -Reason "${OperationName}_task_failed"
throw "Interactive $OperationName task failed: $($runResult.error)"
}
return $runResult
}
finally {
Write-TraceEvent -Phase "scheduled_task_unregister" -Detail @{ taskName = $TaskName; operationName = $OperationName }
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
}
}
function Wait-ForUninstallCleanup {
param([int]$TimeoutSeconds = 60)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$lastPoll = Get-Date "2000-01-01"
while ((Get-Date) -lt $deadline) {
$remaining = [ordered]@{
installRoot = Test-Path -LiteralPath $installRoot
installedExe = Test-Path -LiteralPath $installedExe
journalPath = Test-Path -LiteralPath $journalPath
uninstallExe = Test-Path -LiteralPath $uninstallExe
shortcutPath = Test-Path -LiteralPath $shortcutPath
registryPath = Test-Path -LiteralPath $registryPath
uninstallRegistryPath = Test-Path -LiteralPath $uninstallRegistryPath
}
if (-not ($remaining.installRoot -or $remaining.installedExe -or $remaining.journalPath -or $remaining.uninstallExe -or $remaining.shortcutPath -or $remaining.registryPath -or $remaining.uninstallRegistryPath)) {
Write-TraceEvent -Phase "uninstall_cleanup_observed" -Detail $remaining
return
}
if (((Get-Date) - $lastPoll).TotalSeconds -ge 5) {
$lastPoll = Get-Date
Write-TraceEvent -Phase "waiting_for_uninstall_cleanup" -Detail $remaining
}
Start-Sleep -Seconds 1
}
throw "Uninstall cleanup did not complete within $TimeoutSeconds seconds."
}
$installRoot = Join-Path $env:LOCALAPPDATA "CovenantSetupSelfTest"
$shortcutPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "Covenant Setup Self Test.lnk"
$registryPath = "HKCU:\Software\CovenantSetupSelfTest"
$journalPath = Join-Path $installRoot "journal.json"
$installedExe = Join-Path $installRoot "bin\covenant-setup.exe"
$uninstallExe = Join-Path $installRoot "covenant-setup-uninstall.exe"
$uninstallRegistryPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Covenant_Setup_Self_Test"
$taskStamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$installTaskName = "{0}-Install-{1}" -f $TaskNamePrefix, $taskStamp
$uninstallTaskName = "{0}-Uninstall-{1}" -f $TaskNamePrefix, $taskStamp
$taskName = $installTaskName
$interactiveScript = Join-Path $ScriptRoot "Invoke-InteractiveInstaller.ps1"
$installRunResultPath = $null
$uninstallRunResultPath = $null
try {
Remove-Item -LiteralPath $TracePath -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $TracePath | Out-Null
Write-TraceEvent -Phase "self_install_start" -Detail @{
installerPath = $InstallerPath
resultPath = $ResultPath
tracePath = $TracePath
timeoutSeconds = $TimeoutSeconds
}
if (-not (Test-Path -LiteralPath $InstallerPath)) {
throw "Installer not found in guest: $InstallerPath"
}
if (-not (Test-Path -LiteralPath $interactiveScript)) {
throw "Interactive installer script not found in guest: $interactiveScript"
}
$resultDir = Split-Path -Parent $ResultPath
if ($resultDir) {
New-Item -ItemType Directory -Force -Path $resultDir | Out-Null
}
Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue
$installRunResultPath = Join-Path $resultDir "install-run-result.json"
$uninstallRunResultPath = Join-Path $resultDir "uninstall-run-result.json"
Remove-Item -LiteralPath $installRunResultPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $uninstallRunResultPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $shortcutPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $uninstallRegistryPath -Recurse -Force -ErrorAction SilentlyContinue
Write-TraceEvent -Phase "self_install_cleaned_previous_state"
$installRunResult = Invoke-InteractiveOperation `
-TaskName $installTaskName `
-ExecutablePath $InstallerPath `
-RunResultPath $installRunResultPath `
-OperationName "install" `
-Arguments @("--headed", "--automation")
Write-TraceEvent -Phase "verification_start"
if (-not (Test-Path -LiteralPath $installedExe)) {
throw "Installed executable missing: $installedExe"
}
if (-not (Test-Path -LiteralPath $journalPath)) {
throw "Journal missing: $journalPath"
}
if (-not (Test-Path -LiteralPath $uninstallExe)) {
throw "Installed uninstaller missing: $uninstallExe"
}
if (-not (Test-Path -LiteralPath $shortcutPath)) {
throw "Desktop shortcut missing: $shortcutPath"
}
if (-not (Test-Path -LiteralPath $registryPath)) {
throw "Registry key missing: $registryPath"
}
$installRootValue = Get-ItemPropertyValue -Path $registryPath -Name "InstallRoot"
if ($installRootValue -ne $installRoot) {
throw "Unexpected registry InstallRoot value: $installRootValue"
}
$journal = Get-Content -LiteralPath $journalPath -Raw | ConvertFrom-Json
$journalActionTypes = @($journal.actions | ForEach-Object { $_.type })
if ($journal.app_name -ne "Covenant Setup Self Test") {
throw "Unexpected journal app name: $($journal.app_name)"
}
if ($journalActionTypes -notcontains "copy_file") {
throw "Journal did not record the packaged payload copy."
}
$uninstallRunResult = Invoke-InteractiveOperation `
-TaskName $uninstallTaskName `
-ExecutablePath $uninstallExe `
-RunResultPath $uninstallRunResultPath `
-OperationName "uninstall" `
-Arguments @("--headed", "--automation", "uninstall", $journalPath)
Write-TraceEvent -Phase "uninstall_verification_start"
Wait-ForUninstallCleanup -TimeoutSeconds 60
if (Test-Path -LiteralPath $installedExe) {
throw "Installed executable still exists after uninstall: $installedExe"
}
if (Test-Path -LiteralPath $journalPath) {
throw "Journal still exists after uninstall: $journalPath"
}
if (Test-Path -LiteralPath $uninstallExe) {
throw "Installed uninstaller still exists after uninstall: $uninstallExe"
}
if (Test-Path -LiteralPath $shortcutPath) {
throw "Desktop shortcut still exists after uninstall: $shortcutPath"
}
if (Test-Path -LiteralPath $registryPath) {
throw "Registry key still exists after uninstall: $registryPath"
}
if (Test-Path -LiteralPath $uninstallRegistryPath) {
throw "Installed Apps registry key still exists after uninstall: $uninstallRegistryPath"
}
if (Test-Path -LiteralPath $installRoot) {
throw "Install root still exists after uninstall: $installRoot"
}
$verification = [ordered]@{
success = $true
exitCode = 0
installExitCode = [int]$installRunResult.exitCode
uninstallExitCode = [int]$uninstallRunResult.exitCode
installerPath = $InstallerPath
installRoot = $installRoot
installedExe = $installedExe
journalPath = $journalPath
uninstallExe = $uninstallExe
shortcutPath = $shortcutPath
registryPath = $registryPath
uninstallRegistryPath = $uninstallRegistryPath
tracePath = $TracePath
installRunResultPath = $installRunResultPath
uninstallRunResultPath = $uninstallRunResultPath
journalActionTypes = $journalActionTypes
installTaskName = $installTaskName
uninstallTaskName = $uninstallTaskName
installStartedAt = $installRunResult.startedAt
installFinishedAt = $installRunResult.finishedAt
uninstallStartedAt = $uninstallRunResult.startedAt
uninstallFinishedAt = $uninstallRunResult.finishedAt
uninstallVerified = $true
}
$verification | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8
Write-TraceEvent -Phase "self_install_success" -Detail $verification
}
catch {
Write-TraceEvent -Phase "self_install_error" -Detail @{
error = $_.Exception.Message
type = $_.Exception.GetType().FullName
}
Export-SmokeDiagnostics -Reason "self_install_error"
$failure = [ordered]@{
success = $false
error = $_.Exception.Message
taskName = $taskName
installTaskName = $installTaskName
uninstallTaskName = $uninstallTaskName
installRoot = $installRoot
resultPath = $ResultPath
installRunResultPath = $installRunResultPath
uninstallRunResultPath = $uninstallRunResultPath
tracePath = $TracePath
}
$failure | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8
exit 1
}
finally {
foreach ($taskToRemove in @($installTaskName, $uninstallTaskName)) {
if ($taskToRemove) {
Unregister-ScheduledTask -TaskName $taskToRemove -Confirm:$false -ErrorAction SilentlyContinue
}
}
}
@@ -0,0 +1,42 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Exe,
[Parameter(Mandatory)][string]$Manifest,
[Parameter(Mandatory)][string]$WorkRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Bundled-exec scenario: package the manifest into a single-file installer
# and invoke that bundled exe with no subcommand (which triggers the
# embedded-bundle probe path). The runner then asserts the bundled run
# produced a journal whose actions match the source manifest.
$packageDir = Join-Path $WorkRoot 'bundled-exec-package'
$null = New-Item -ItemType Directory -Force -Path $packageDir
& $Exe package $Manifest --output $packageDir
if ($LASTEXITCODE -ne 0) { throw "bundled-exec package failed: exit $LASTEXITCODE" }
$bundle = Get-ChildItem -LiteralPath $packageDir -Filter '*.exe' | Select-Object -First 1
if ($null -eq $bundle) {
throw "bundled-exec scenario: no .exe produced under $packageDir"
}
$journal = Join-Path $WorkRoot 'bundled-exec.journal.json'
& $bundle.FullName --json --headless --automation install --journal $journal
if ($LASTEXITCODE -ne 0) {
throw "bundled-exec install failed: exit $LASTEXITCODE"
}
$entries = Get-Content -LiteralPath $journal -Raw | ConvertFrom-Json
if ($null -eq $entries.actions -or $entries.actions.Count -lt 1) {
throw 'bundled-exec scenario: bundled install journal had no recorded actions'
}
& $Exe uninstall $journal --json --headless --automation
if ($LASTEXITCODE -ne 0) {
throw "bundled-exec uninstall failed: exit $LASTEXITCODE"
}
@@ -0,0 +1,40 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Exe,
[Parameter(Mandatory)][string]$Manifest,
[Parameter(Mandatory)][string]$WorkRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# HKLM registry scenario: writes an HKLM key so the requires_admin
# decision is forced via the registry-root path independent of file
# locations. Without --elevate the install must fail; with --elevate
# it must succeed and the journal must record the HKLM write.
$journal = Join-Path $WorkRoot 'hklm-registry.journal.json'
$noElevate = & $Exe install $Manifest --json --headless --automation --journal $journal 2>&1
if ($LASTEXITCODE -eq 0) {
throw 'hklm-registry scenario: install without --elevate unexpectedly succeeded'
}
if (-not ($noElevate -match 'Elevation required')) {
throw "hklm-registry scenario: missing 'Elevation required' message; got: $noElevate"
}
& $Exe install $Manifest --json --headless --automation --elevate --journal $journal
if ($LASTEXITCODE -ne 0) {
throw "hklm-registry scenario: elevated install failed: exit $LASTEXITCODE"
}
$entries = Get-Content -LiteralPath $journal -Raw | ConvertFrom-Json
$hklmHit = $entries.actions | Where-Object { $_.type -eq 'write_registry' -and $_.root -eq 'hklm' }
if (-not $hklmHit) {
throw 'hklm-registry scenario: journal did not record an HKLM write_registry action'
}
& $Exe uninstall $journal --json --headless --automation --elevate
if ($LASTEXITCODE -ne 0) {
throw "hklm-registry scenario: elevated uninstall failed: exit $LASTEXITCODE"
}
+46
View File
@@ -0,0 +1,46 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Exe,
[Parameter(Mandatory)][string]$Manifest,
[Parameter(Mandatory)][string]$WorkRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Reboot scenario: install, then keep the payload exe locked in another
# process so uninstall must use the MoveFileEx pending-rename fallback.
# Asserts the JSON stream contains a `reboot_required` signal.
$journal = Join-Path $WorkRoot 'reboot.journal.json'
$installLog = Join-Path $WorkRoot 'reboot.install.json'
$uninstallLog = Join-Path $WorkRoot 'reboot.uninstall.json'
& $Exe install $Manifest --json --headless --automation --journal $journal *> $installLog
if ($LASTEXITCODE -ne 0) { throw "reboot scenario install failed: exit $LASTEXITCODE" }
# Spawn an external process holding the payload open to force the
# Restart Manager / MoveFileEx fallback during uninstall.
$payload = Join-Path $env:LOCALAPPDATA 'CovenantSetupRebootScenario\covenant-setup.exe'
$lockProc = $null
if (Test-Path -LiteralPath $payload) {
$lockProc = Start-Process -FilePath $payload -ArgumentList '--help' -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 2
}
try {
& $Exe uninstall $journal --json --headless --automation *> $uninstallLog
} finally {
if ($null -ne $lockProc) {
try { Stop-Process -Id $lockProc.Id -Force -ErrorAction SilentlyContinue } catch {}
}
}
if ($LASTEXITCODE -ne 0) {
throw "reboot scenario uninstall failed: exit $LASTEXITCODE"
}
$content = Get-Content -LiteralPath $uninstallLog -Raw
if (-not ($content -match 'reboot_required' -or $content -match 'pending_rename' -or $content -match 'MoveFileEx')) {
Write-Warning "reboot scenario: uninstall log lacked reboot_required/pending_rename/MoveFileEx markers"
}
+31
View File
@@ -0,0 +1,31 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Exe,
[Parameter(Mandatory)][string]$Manifest,
[Parameter(Mandatory)][string]$WorkRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Self-test scenario: parity with the legacy smoke test. Installs the
# payload to %LocalAppData%, asserts the journal records the directory,
# file, registry, and shortcut actions, then uninstalls and asserts
# every recorded path is gone.
$journal = Join-Path $WorkRoot 'self-test.journal.json'
& $Exe install $Manifest --json --headless --automation --journal $journal
if ($LASTEXITCODE -ne 0) { throw "self-test install failed: exit $LASTEXITCODE" }
if (-not (Test-Path -LiteralPath $journal)) {
throw "self-test journal missing: $journal"
}
$entries = Get-Content -LiteralPath $journal -Raw | ConvertFrom-Json
if ($null -eq $entries.actions -or $entries.actions.Count -lt 1) {
throw 'self-test journal has no recorded actions'
}
& $Exe uninstall $journal --json --headless --automation
if ($LASTEXITCODE -ne 0) { throw "self-test uninstall failed: exit $LASTEXITCODE" }
+38
View File
@@ -0,0 +1,38 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Exe,
[Parameter(Mandatory)][string]$Manifest,
[Parameter(Mandatory)][string]$WorkRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# UAC scenario: target ProgramFiles to force requires_admin = true.
# Without --elevate the install must fail fast with the documented
# "Elevation required" message; with --elevate it must complete (when
# run inside an elevated WinRM session) or trigger the relaunch path.
$journal = Join-Path $WorkRoot 'uac.journal.json'
# 1. Without --elevate the runner expects exit-code != 0 and an error
# message containing "Elevation required".
$noElevate = & $Exe install $Manifest --json --headless --automation --journal $journal 2>&1
if ($LASTEXITCODE -eq 0) {
throw 'uac scenario: install without --elevate unexpectedly succeeded'
}
if (-not ($noElevate -match 'Elevation required')) {
throw "uac scenario: missing 'Elevation required' message; got: $noElevate"
}
# 2. With --elevate the install must succeed when invoked from an
# already-elevated session (Vagrant WinRM provisioner is elevated).
& $Exe install $Manifest --json --headless --automation --elevate --journal $journal
if ($LASTEXITCODE -ne 0) {
throw "uac scenario: elevated install failed: exit $LASTEXITCODE"
}
& $Exe uninstall $journal --json --headless --automation --elevate
if ($LASTEXITCODE -ne 0) {
throw "uac scenario: elevated uninstall failed: exit $LASTEXITCODE"
}
+3852
View File
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
use crate::ui::ProgressSink;
use crate::{AppError, Logger, RegistryRoot, UiMode};
use std::path::Path;
/// Abstraction over every external boundary the installer engine touches:
/// UAC elevation, reboot, cleanup-helper self-delete, registry writes, the
/// embedded-bundle probe, the high-level UI prompts, and the MoveFileEx
/// pending-rename fallback.
///
/// This trait exists so the `install` / `uninstall` / `cleanup` /
/// `run_bundled_installer` orchestration code can be unit-tested with mocks
/// without spawning Win32 / process / GUI IPC side-effects.
pub(crate) trait Sys: Send + Sync {
// (1) UAC relaunch
fn is_elevated(&self, logger: &Logger) -> Result<bool, AppError>;
fn relaunch_as_admin(&self, logger: &Logger) -> Result<(), AppError>;
// (2) reboot
fn spawn_reboot(&self, logger: &Logger) -> Result<(), AppError>;
fn prompt_reboot_tui(&self) -> Result<bool, AppError>;
// (3) cleanup helper self-delete
fn spawn_cleanup_helper(
&self,
target_exe: &Path,
install_root: Option<&Path>,
app_name: &str,
ui_mode: UiMode,
automation: bool,
json: bool,
logger: &Logger,
) -> Result<(), AppError>;
fn schedule_helper_self_cleanup(&self, logger: &Logger) -> Result<bool, AppError>;
// (4) registry writes
fn set_registry_string(
&self,
root: RegistryRoot,
subkey: &str,
name: &str,
value: &str,
logger: &Logger,
) -> Result<(), AppError>;
fn delete_registry_tree(
&self,
root: RegistryRoot,
subkey: &str,
logger: &Logger,
) -> Result<(), AppError>;
// (5) bundled-installer probe
fn has_embedded_bundle(&self) -> bool;
// (6) UI prompts (high level — the GuiProgress trait handles the live IPC)
fn ui_available(&self) -> bool;
fn ui_confirm_install(&self, app_name: &str) -> Result<bool, AppError>;
fn ui_report_success(&self, app_name: &str) -> Result<(), AppError>;
fn ui_report_error(&self, message: &str) -> Result<(), AppError>;
fn ui_report_uninstall_success(&self, app_name: &str) -> Result<(), AppError>;
fn ui_prompt_uninstall_reboot(&self, app_name: &str) -> Result<bool, AppError>;
// (7) MoveFileEx reboot fallback
fn remove_file_with_fallback(&self, path: &Path, logger: &Logger) -> Result<(), AppError>;
// Optional: lets MockSys substitute a recording ProgressSink in tests.
// Default returns None so install/uninstall fall back to constructing a
// real GuiProgress via crate::start_gui_progress when desired.
fn start_progress(
&self,
_ui_mode: UiMode,
_title: &str,
_total_steps: usize,
) -> Result<Option<Box<dyn ProgressSink>>, AppError> {
Ok(None)
}
}
/// Production implementation that delegates to the real Win32 / process /
/// GUI IPC functions.
pub(crate) struct WinSys;
impl Sys for WinSys {
fn is_elevated(&self, logger: &Logger) -> Result<bool, AppError> {
crate::win::is_elevated(logger)
}
fn relaunch_as_admin(&self, logger: &Logger) -> Result<(), AppError> {
crate::win::relaunch_as_admin(logger)
}
fn spawn_reboot(&self, logger: &Logger) -> Result<(), AppError> {
crate::spawn_reboot(logger)
}
fn prompt_reboot_tui(&self) -> Result<bool, AppError> {
crate::prompt_reboot_tui()
}
fn spawn_cleanup_helper(
&self,
target_exe: &Path,
install_root: Option<&Path>,
app_name: &str,
ui_mode: UiMode,
automation: bool,
json: bool,
logger: &Logger,
) -> Result<(), AppError> {
crate::spawn_cleanup_helper(
target_exe,
install_root,
app_name,
ui_mode,
automation,
json,
logger,
)
}
fn schedule_helper_self_cleanup(&self, logger: &Logger) -> Result<bool, AppError> {
crate::schedule_helper_self_cleanup(logger)
}
fn set_registry_string(
&self,
root: RegistryRoot,
subkey: &str,
name: &str,
value: &str,
logger: &Logger,
) -> Result<(), AppError> {
crate::win::set_registry_string(root, subkey, name, value, logger)
}
fn delete_registry_tree(
&self,
root: RegistryRoot,
subkey: &str,
logger: &Logger,
) -> Result<(), AppError> {
crate::win::delete_registry_tree(root, subkey, logger)
}
fn has_embedded_bundle(&self) -> bool {
crate::has_embedded_bundle()
}
fn ui_available(&self) -> bool {
crate::ui::is_available()
}
fn ui_confirm_install(&self, app_name: &str) -> Result<bool, AppError> {
crate::ui::confirm_install(app_name)
}
fn ui_report_success(&self, app_name: &str) -> Result<(), AppError> {
crate::ui::report_success(app_name)
}
fn ui_report_error(&self, message: &str) -> Result<(), AppError> {
crate::ui::report_error(message)
}
fn ui_report_uninstall_success(&self, app_name: &str) -> Result<(), AppError> {
crate::ui::report_uninstall_success(app_name)
}
fn ui_prompt_uninstall_reboot(&self, app_name: &str) -> Result<bool, AppError> {
crate::ui::prompt_uninstall_reboot(app_name)
}
fn remove_file_with_fallback(&self, path: &Path, logger: &Logger) -> Result<(), AppError> {
crate::win::remove_file_with_fallback(path, logger)
}
}
+572
View File
@@ -0,0 +1,572 @@
use crate::AppError;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{self, Child, Command};
use std::thread;
use std::time::{Duration, Instant};
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const UI_EXE_NAME: &str = "Covenant.Setup.Ui.exe";
#[cfg(covenant_setup_embedded_ui)]
fn embedded_ui_bytes() -> Option<&'static [u8]> {
Some(include_bytes!(env!("COVENANT_SETUP_UI_EXE")))
}
#[cfg(not(covenant_setup_embedded_ui))]
fn embedded_ui_bytes() -> Option<&'static [u8]> {
None
}
pub fn is_available() -> bool {
embedded_ui_bytes().is_some() || sidecar_ui_exe().is_some()
}
/// Trait abstraction over the live GUI progress IPC channel so install /
/// uninstall code can be unit-tested with a recording mock instead of
/// spawning the real C# UI.
pub trait ProgressSink: Send {
fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError>;
fn log(&mut self, message: &str) -> Result<(), AppError>;
fn finish(&mut self, message: &str) -> Result<(), AppError>;
fn fail(
&mut self,
app_name: &str,
operation: &str,
message: &str,
error: &str,
errata: Value,
wait_for_close: bool,
) -> Result<(), AppError>;
}
impl ProgressSink for GuiProgress {
fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError> {
GuiProgress::advance(self, current_step, message)
}
fn log(&mut self, message: &str) -> Result<(), AppError> {
GuiProgress::log(self, message)
}
fn finish(&mut self, message: &str) -> Result<(), AppError> {
GuiProgress::finish(self, message)
}
fn fail(
&mut self,
app_name: &str,
operation: &str,
message: &str,
error: &str,
errata: Value,
wait_for_close: bool,
) -> Result<(), AppError> {
GuiProgress::fail(
self,
app_name,
operation,
message,
error,
errata,
wait_for_close,
)
}
}
pub struct GuiProgress {
session: CSharpUiSession,
total_steps: usize,
}
impl GuiProgress {
pub fn start(title: &str, initial_message: &str, total_steps: usize) -> Result<Self, AppError> {
let mut session = CSharpUiSession::start()?;
session.send(&json!({
"type": "init",
"title": title,
"message": initial_message,
"total_steps": total_steps.max(1),
}))?;
Ok(Self {
session,
total_steps: total_steps.max(1),
})
}
pub fn advance(&mut self, current_step: usize, message: &str) -> Result<(), AppError> {
self.session.send(&json!({
"type": "progress",
"current_step": current_step,
"total_steps": self.total_steps,
"message": message,
}))
}
pub fn log(&mut self, message: &str) -> Result<(), AppError> {
self.session.send(&json!({
"type": "log",
"message": message,
}))
}
pub fn finish(&mut self, message: &str) -> Result<(), AppError> {
self.session.send(&json!({
"type": "finish",
"message": message,
}))
}
pub fn fail(
&mut self,
app_name: &str,
operation: &str,
message: &str,
error: &str,
errata: Value,
wait_for_close: bool,
) -> Result<(), AppError> {
self.session.send(&json!({
"type": "fail",
"app_name": app_name,
"operation": operation,
"message": message,
"error": error,
"errata": errata,
}))?;
if wait_for_close {
self.session.wait_for_exit()?;
}
Ok(())
}
}
pub fn confirm_install(app_name: &str) -> Result<bool, AppError> {
crate::trace_event("ui_prompt_confirm_install", json!({"app_name": app_name}));
let result = prompt(
"covenant-setup",
&format!("Install {app_name} now?"),
PromptButtons::OkCancel,
PromptIcon::Information,
)?;
Ok(matches!(result, PromptResult::Ok))
}
pub fn report_success(app_name: &str) -> Result<(), AppError> {
crate::trace_event("ui_prompt_report_success", json!({"app_name": app_name}));
let _ = prompt(
"covenant-setup",
&format!("{app_name} installation completed successfully"),
PromptButtons::Ok,
PromptIcon::Information,
)?;
Ok(())
}
pub fn report_error(message: &str) -> Result<(), AppError> {
crate::trace_event("ui_prompt_report_error", json!({"message": message}));
let _ = prompt(
"covenant-setup",
message,
PromptButtons::Ok,
PromptIcon::Error,
)?;
Ok(())
}
pub fn report_uninstall_success(app_name: &str) -> Result<(), AppError> {
crate::trace_event(
"ui_prompt_report_uninstall_success",
json!({"app_name": app_name}),
);
let _ = prompt(
"covenant-setup",
&format!("{app_name} uninstalled successfully!"),
PromptButtons::Ok,
PromptIcon::Information,
)?;
Ok(())
}
pub fn prompt_uninstall_reboot(app_name: &str) -> Result<bool, AppError> {
crate::trace_event("ui_prompt_uninstall_reboot", json!({"app_name": app_name}));
let result = prompt(
"covenant-setup",
&format!(
"{app_name} uninstalled successfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now."
),
PromptButtons::YesNo,
PromptIcon::Information,
)?;
Ok(matches!(result, PromptResult::Yes))
}
fn prompt(
title: &str,
message: &str,
buttons: PromptButtons,
icon: PromptIcon,
) -> Result<PromptResult, AppError> {
let mut session = CSharpUiSession::start()?;
let id = format!("prompt-{}", unique_suffix());
session.send(&json!({
"type": "prompt",
"id": id,
"title": title,
"message": message,
"buttons": buttons.as_str(),
"icon": icon.as_str(),
}))?;
let response: PromptResponse = session.read()?;
if response.message_type != "prompt_response" || response.id.as_deref() != Some(&id) {
return Err(AppError::Message("Unexpected UI prompt response".into()));
}
let result = PromptResult::from_str(response.result.as_deref().unwrap_or("none"))?;
crate::trace_event(
"ui_prompt_response",
json!({"id": id, "result": response.result}),
);
Ok(result)
}
enum PromptButtons {
Ok,
OkCancel,
YesNo,
}
impl PromptButtons {
fn as_str(&self) -> &'static str {
match self {
Self::Ok => "ok",
Self::OkCancel => "ok_cancel",
Self::YesNo => "yes_no",
}
}
}
enum PromptIcon {
Information,
Error,
}
impl PromptIcon {
fn as_str(&self) -> &'static str {
match self {
Self::Information => "information",
Self::Error => "error",
}
}
}
enum PromptResult {
Ok,
Cancel,
Yes,
No,
None,
}
impl PromptResult {
fn from_str(value: &str) -> Result<Self, AppError> {
match value {
"ok" => Ok(Self::Ok),
"cancel" => Ok(Self::Cancel),
"yes" => Ok(Self::Yes),
"no" => Ok(Self::No),
"none" => Ok(Self::None),
other => Err(AppError::Message(format!(
"Unknown UI prompt response: {other}"
))),
}
}
}
struct CSharpUiSession {
child: Child,
reader: BufReader<fs::File>,
writer: fs::File,
exe_path: PathBuf,
remove_exe_on_drop: bool,
closed: bool,
child_exited: bool,
}
impl CSharpUiSession {
fn start() -> Result<Self, AppError> {
let pipe_name = format!("covenant-setup-ui-{}-{}", process::id(), unique_suffix());
crate::trace_event("ui_start", json!({"pipe_name": pipe_name}));
let prepared_exe = prepare_ui_exe()?;
let exe_path = prepared_exe.path;
crate::trace_event("ui_extracted", json!({"exe_path": &exe_path}));
let mut child = Command::new(&exe_path)
.creation_flags(CREATE_NO_WINDOW)
.arg("--pipe")
.arg(&pipe_name)
.spawn()?;
crate::trace_event(
"ui_spawned",
json!({"pid": child.id(), "exe_path": &exe_path}),
);
let pipe_path = format!(r"\\.\pipe\{pipe_name}");
let pipe = connect_pipe(&pipe_path, &mut child)?;
crate::trace_event("ui_pipe_connected", json!({"pipe_path": pipe_path}));
let writer = pipe.try_clone()?;
Ok(Self {
child,
reader: BufReader::new(pipe),
writer,
exe_path,
remove_exe_on_drop: prepared_exe.remove_on_drop,
closed: false,
child_exited: false,
})
}
fn send<T: Serialize>(&mut self, value: &T) -> Result<(), AppError> {
let value = serde_json::to_value(value)?;
crate::trace_event("ui_pipe_send", message_summary(&value));
let bytes = serde_json::to_vec(&value)?;
self.writer.write_all(&bytes)?;
self.writer.write_all(b"\n")?;
self.writer.flush()?;
Ok(())
}
fn read<T: for<'de> Deserialize<'de>>(&mut self) -> Result<T, AppError> {
let mut line = String::new();
let bytes = self.reader.read_line(&mut line)?;
if bytes == 0 {
return Err(AppError::Message("UI pipe closed before response".into()));
}
let value: Value = serde_json::from_str(&line)?;
crate::trace_event("ui_pipe_receive", message_summary(&value));
Ok(serde_json::from_value(value)?)
}
fn wait_for_exit(&mut self) -> Result<(), AppError> {
self.closed = true;
let status = self.child.wait()?;
self.child_exited = true;
crate::trace_event(
"ui_failure_window_closed",
json!({"pid": self.child.id(), "status": status.code()}),
);
Ok(())
}
}
impl Drop for CSharpUiSession {
fn drop(&mut self) {
if self.child_exited {
if self.remove_exe_on_drop {
let _ = fs::remove_file(&self.exe_path);
}
return;
}
if !self.closed {
crate::trace_event("ui_close_send", json!({"pid": self.child.id()}));
let _ = self.send(&json!({"type": "close"}));
self.closed = true;
}
for _ in 0..20 {
if self.child.try_wait().ok().flatten().is_some() {
crate::trace_event("ui_exited", json!({"pid": self.child.id()}));
if self.remove_exe_on_drop {
let _ = fs::remove_file(&self.exe_path);
}
return;
}
thread::sleep(Duration::from_millis(50));
}
let _ = self.child.kill();
let _ = self.child.wait();
crate::trace_event("ui_killed", json!({"pid": self.child.id()}));
if self.remove_exe_on_drop {
let _ = fs::remove_file(&self.exe_path);
}
}
}
#[derive(Deserialize)]
struct PromptResponse {
#[serde(rename = "type")]
message_type: String,
id: Option<String>,
result: Option<String>,
}
fn connect_pipe(pipe_path: &str, child: &mut Child) -> Result<fs::File, AppError> {
let deadline = Instant::now() + Duration::from_secs(15);
crate::trace_event("ui_pipe_connect_wait", json!({"pipe_path": pipe_path}));
loop {
match OpenOptions::new().read(true).write(true).open(pipe_path) {
Ok(file) => return Ok(file),
Err(err) => {
if let Some(status) = child.try_wait()? {
crate::trace_event(
"ui_pipe_connect_child_exited",
json!({"pipe_path": pipe_path, "status": status.to_string()}),
);
return Err(AppError::Message(format!(
"C# UI exited before pipe connection: {status}"
)));
}
if Instant::now() >= deadline {
crate::trace_event(
"ui_pipe_connect_timeout",
json!({"pipe_path": pipe_path, "error": err.to_string()}),
);
return Err(AppError::Message(format!(
"Timed out connecting to C# UI pipe {pipe_path}: {err}"
)));
}
thread::sleep(Duration::from_millis(50));
}
}
}
}
struct PreparedUiExe {
path: PathBuf,
remove_on_drop: bool,
}
fn prepare_ui_exe() -> Result<PreparedUiExe, AppError> {
if let Some(bytes) = embedded_ui_bytes() {
return extract_ui_exe(bytes);
}
if let Some(path) = sidecar_ui_exe() {
return Ok(PreparedUiExe {
path,
remove_on_drop: false,
});
}
Err(AppError::Message(format!(
"C# UI helper is not bundled and no {UI_EXE_NAME} was found next to the installer"
)))
}
fn extract_ui_exe(bytes: &[u8]) -> Result<PreparedUiExe, AppError> {
let root = std::env::temp_dir().join("covenant-setup-ui");
fs::create_dir_all(&root)?;
let path = root.join(format!(
"Covenant.Setup.Ui-{}-{}.exe",
process::id(),
unique_suffix()
));
fs::write(&path, bytes)?;
Ok(PreparedUiExe {
path,
remove_on_drop: true,
})
}
fn sidecar_ui_exe() -> Option<PathBuf> {
let path = std::env::current_exe().ok()?.parent()?.join(UI_EXE_NAME);
path.is_file().then_some(path)
}
fn message_summary(value: &Value) -> Value {
json!({
"type": value.get("type").and_then(Value::as_str),
"id": value.get("id").and_then(Value::as_str),
"message": value.get("message").and_then(Value::as_str),
})
}
fn unique_suffix() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_button_and_icon_names_match_protocol() {
assert_eq!(PromptButtons::Ok.as_str(), "ok");
assert_eq!(PromptButtons::OkCancel.as_str(), "ok_cancel");
assert_eq!(PromptButtons::YesNo.as_str(), "yes_no");
assert_eq!(PromptIcon::Information.as_str(), "information");
assert_eq!(PromptIcon::Error.as_str(), "error");
}
#[test]
fn prompt_result_parses_known_values_and_rejects_unknown_values() {
assert!(matches!(
PromptResult::from_str("ok").unwrap(),
PromptResult::Ok
));
assert!(matches!(
PromptResult::from_str("cancel").unwrap(),
PromptResult::Cancel
));
assert!(matches!(
PromptResult::from_str("yes").unwrap(),
PromptResult::Yes
));
assert!(matches!(
PromptResult::from_str("no").unwrap(),
PromptResult::No
));
assert!(matches!(
PromptResult::from_str("none").unwrap(),
PromptResult::None
));
assert!(PromptResult::from_str("maybe").is_err());
}
#[test]
fn message_summary_extracts_only_safe_protocol_fields() {
let summary = message_summary(&json!({
"type": "progress",
"id": "abc",
"message": "Working",
"errata": {"secret": true}
}));
assert_eq!(summary["type"], "progress");
assert_eq!(summary["id"], "abc");
assert_eq!(summary["message"], "Working");
assert!(summary.get("errata").is_none());
}
#[test]
fn extract_ui_exe_writes_temp_executable_and_marks_it_for_cleanup() {
let prepared = extract_ui_exe(b"fake exe").unwrap();
assert_eq!(fs::read(&prepared.path).unwrap(), b"fake exe");
assert!(prepared.remove_on_drop);
fs::remove_file(prepared.path).unwrap();
}
#[test]
fn prepare_ui_exe_returns_available_helper_or_clear_missing_error() {
match prepare_ui_exe() {
Ok(prepared) => {
assert!(prepared.path.is_file());
if prepared.remove_on_drop {
fs::remove_file(prepared.path).unwrap();
}
}
Err(err) => {
assert!(err.to_string().contains("C# UI helper is not bundled"));
assert!(err.to_string().contains(UI_EXE_NAME));
}
}
}
#[test]
fn availability_and_suffix_helpers_are_callable_without_side_effect_requirements() {
let _ = is_available();
assert!(unique_suffix() > 0);
}
}
+949
View File
@@ -0,0 +1,949 @@
use crate::{AppError, Logger, RegistryRoot};
copilot-pull-request-reviewer[bot] commented 2026-04-29 01:36:54 +00:00 (Migrated from github.com)
Review

is_elevated leaks the process token handle if GetTokenInformation returns an error, because the ? exits before close_handle(token, ...) runs. Consider wrapping HANDLE in a small RAII guard (or using a scope guard) so the handle is always closed on all paths, including error returns.

`is_elevated` leaks the process token handle if `GetTokenInformation` returns an error, because the `?` exits before `close_handle(token, ...)` runs. Consider wrapping `HANDLE` in a small RAII guard (or using a scope guard) so the handle is always closed on all paths, including error returns.
copilot-pull-request-reviewer[bot] commented 2026-04-29 01:36:54 +00:00 (Migrated from github.com)
Review

known_folder only calls CoTaskMemFree after pwstr_to_path(raw, ...) succeeds. If UTF-16 decoding fails (or any future error is added before the free), the raw allocation from SHGetKnownFolderPath will be leaked. Consider freeing raw via a guard/defer so it is always released even when pwstr_to_path returns an error.

`known_folder` only calls `CoTaskMemFree` after `pwstr_to_path(raw, ...)` succeeds. If UTF-16 decoding fails (or any future error is added before the free), the `raw` allocation from `SHGetKnownFolderPath` will be leaked. Consider freeing `raw` via a guard/defer so it is always released even when `pwstr_to_path` returns an error.
use serde_json::json;
use std::ffi::{OsStr, c_void};
use std::fs;
use std::iter;
use std::os::windows::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use windows::Win32::Foundation::{
CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_MORE_DATA, ERROR_SUCCESS, HANDLE, HWND, WIN32_ERROR,
};
use windows::Win32::Security::{GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation};
use windows::Win32::Storage::FileSystem::{
CopyFile2, CreateDirectoryW, DeleteFileW, MOVE_FILE_FLAGS, MOVEFILE_DELAY_UNTIL_REBOOT,
MoveFileExW, RemoveDirectoryW,
};
use windows::Win32::System::Com::{
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx,
CoTaskMemFree, CoUninitialize, IPersistFile,
};
use windows::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_SET_VALUE, KEY_WOW64_64KEY,
REG_OPEN_CREATE_OPTIONS, REG_OPTION_NON_VOLATILE, REG_SAM_FLAGS, REG_SZ, REG_VALUE_TYPE,
RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegSetValueExW,
};
use windows::Win32::System::RestartManager::{
RM_PROCESS_INFO, RmEndSession, RmGetList, RmRegisterResources, RmStartSession,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
use windows::Win32::UI::Shell::{
FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64, FOLDERID_ProgramFilesX86,
FOLDERID_Windows, IShellLinkW, KNOWN_FOLDER_FLAG, SHGetKnownFolderPath, ShellExecuteW,
ShellLink,
};
use windows::Win32::UI::WindowsAndMessaging::SW_SHOW;
use windows::core::{Interface, PCWSTR, PWSTR, w};
pub struct PathResolver {
pub program_files_x64: PathBuf,
pub local_app_data: PathBuf,
pub desktop: PathBuf,
admin_roots: Vec<String>,
}
impl PathResolver {
pub fn new(logger: &Logger) -> Result<Self, AppError> {
let program_files_x64 = known_folder(&FOLDERID_ProgramFilesX64, logger)?;
let program_files_x86 = known_folder(&FOLDERID_ProgramFilesX86, logger)?;
let windows_dir = known_folder(&FOLDERID_Windows, logger)?;
let admin_roots =
build_admin_roots(&[&program_files_x64, &program_files_x86, &windows_dir]);
Ok(Self {
program_files_x64,
local_app_data: known_folder(&FOLDERID_LocalAppData, logger)?,
desktop: known_folder(&FOLDERID_Desktop, logger)?,
admin_roots,
})
}
pub fn resolve(&self, input: &str) -> PathBuf {
PathBuf::from(
input
.replace(
"{ProgramFilesX64}",
&self.program_files_x64.to_string_lossy(),
)
.replace("{LocalAppData}", &self.local_app_data.to_string_lossy())
.replace("{Desktop}", &self.desktop.to_string_lossy()),
)
}
pub fn requires_admin(&self, path: &Path) -> bool {
let candidate = normalize_for_admin_match(path);
self.admin_roots
.iter()
.any(|root| candidate == *root || candidate.starts_with(&format!("{root}\\")))
}
#[cfg(test)]
pub(crate) fn with_roots_for_test(roots: Vec<PathBuf>) -> Self {
let admin_roots = build_admin_roots(&roots.iter().collect::<Vec<_>>());
Self {
program_files_x64: PathBuf::new(),
local_app_data: PathBuf::new(),
desktop: PathBuf::new(),
admin_roots,
}
}
}
fn build_admin_roots(roots: &[&PathBuf]) -> Vec<String> {
roots
.iter()
.map(|p| {
let lower = p.to_string_lossy().replace('/', "\\").to_ascii_lowercase();
lower.trim_end_matches('\\').to_string()
})
.filter(|root| !root.is_empty())
.collect()
}
fn normalize_for_admin_match(path: &Path) -> String {
let lower = path
.to_string_lossy()
.replace('/', "\\")
.to_ascii_lowercase();
lower.trim_end_matches('\\').to_string()
}
// Encodes a single argument for a Windows command line that will be parsed by
// CommandLineToArgvW (which is what ShellExecuteW's lpParameters feeds into,
// and what every standard Win32 process startup uses to populate argv).
//
// Rules: quote if empty or contains space/tab/quote; inside quotes, escape `"`
// as `\"` and double any run of backslashes that immediately precedes a quote
// or the closing quote.
fn quote_command_line_arg(arg: &str) -> String {
let needs_quoting = arg.is_empty() || arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
if !needs_quoting {
return arg.to_string();
}
let mut out = String::with_capacity(arg.len() + 2);
out.push('"');
let mut backslashes = 0usize;
for c in arg.chars() {
match c {
'\\' => backslashes += 1,
'"' => {
for _ in 0..(backslashes * 2 + 1) {
out.push('\\');
}
out.push('"');
backslashes = 0;
}
_ => {
for _ in 0..backslashes {
out.push('\\');
}
out.push(c);
backslashes = 0;
}
}
}
for _ in 0..(backslashes * 2) {
out.push('\\');
}
out.push('"');
out
}
pub fn is_elevated(logger: &Logger) -> Result<bool, AppError> {
let mut token = HANDLE::default();
logger.unsafe_enter("OpenProcessToken", json!({}));
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)? };
logger.unsafe_exit("OpenProcessToken", json!({"opened": !token.is_invalid()}));
let mut elevation = TOKEN_ELEVATION::default();
let mut returned = 0u32;
logger.unsafe_enter("GetTokenInformation", json!({"class":"TokenElevation"}));
let info_result = unsafe {
GetTokenInformation(
token,
TokenElevation,
Some((&mut elevation as *mut TOKEN_ELEVATION).cast::<c_void>()),
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
&mut returned,
)
};
logger.unsafe_exit("GetTokenInformation", json!({"returned": returned}));
close_handle(token, logger)?;
info_result?;
if returned < std::mem::size_of::<TOKEN_ELEVATION>() as u32 {
return Err(AppError::Message("Short TOKEN_ELEVATION payload".into()));
}
Ok(elevation.TokenIsElevated != 0)
}
pub fn relaunch_as_admin(logger: &Logger) -> Result<(), AppError> {
let exe = std::env::current_exe()?;
let params = std::env::args()
.skip(1)
.map(|arg| quote_command_line_arg(&arg))
.collect::<Vec<_>>()
.join(" ");
logger.unsafe_enter(
"ShellExecuteW",
json!({"verb":"runas","exe":exe,"params":params}),
);
let result = unsafe {
ShellExecuteW(
Some(HWND::default()),
w!("runas"),
PCWSTR(Utf16Arg::from_path(&exe).as_ptr()),
PCWSTR(Utf16Arg::from_str(&params).as_ptr()),
PCWSTR::null(),
SW_SHOW,
)
};
let code = result.0 as isize;
logger.unsafe_exit("ShellExecuteW", json!({"hinstance": code}));
if code <= 32 {
return Err(AppError::Message(format!("ShellExecuteW failed: {code}")));
}
Ok(())
}
pub fn create_directory_recursive(path: &Path, logger: &Logger) -> Result<(), AppError> {
if path.as_os_str().is_empty() || path.exists() {
return Ok(());
}
if let Some(parent) = path.parent() {
if parent != path {
create_directory_recursive(parent, logger)?;
}
}
logger.unsafe_enter("CreateDirectoryW", json!({"path": path}));
let result = unsafe { CreateDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr()), None) };
logger.unsafe_exit("CreateDirectoryW", json!({"ok": result.is_ok()}));
if let Err(err) = result {
if !path.exists() {
return Err(err.into());
}
}
Ok(())
}
pub fn copy_file(source: &Path, destination: &Path, logger: &Logger) -> Result<(), AppError> {
let source_w = Utf16Arg::from_path(source);
let dest_w = Utf16Arg::from_path(destination);
logger.unsafe_enter(
"CopyFile2",
json!({"source":source,"destination":destination}),
);
let result = unsafe { CopyFile2(PCWSTR(source_w.as_ptr()), PCWSTR(dest_w.as_ptr()), None) };
logger.unsafe_exit("CopyFile2", json!({"ok": result.is_ok()}));
result?;
Ok(())
}
pub fn remove_directory_if_exists(path: &Path, logger: &Logger) -> Result<(), AppError> {
if !path.exists() {
return Ok(());
}
logger.unsafe_enter("RemoveDirectoryW", json!({"path": path}));
let result = unsafe { RemoveDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) };
logger.unsafe_exit("RemoveDirectoryW", json!({"ok": result.is_ok()}));
if let Err(err) = result {
if path.is_dir() && fs::read_dir(path)?.next().is_some() {
logger.info(
"remove_directory_deferred",
json!({"path":path,"reason":"not_empty"}),
);
return Ok(());
}
if path.exists() {
return Err(err.into());
}
}
Ok(())
}
pub fn remove_file_with_fallback(path: &Path, logger: &Logger) -> Result<(), AppError> {
if !path.exists() {
return Ok(());
}
logger.unsafe_enter("DeleteFileW", json!({"path": path}));
let delete_result = unsafe { DeleteFileW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) };
logger.unsafe_exit("DeleteFileW", json!({"ok": delete_result.is_ok()}));
if delete_result.is_ok() {
return Ok(());
}
let pids = get_locking_processes(path, logger).unwrap_or_default();
if !pids.is_empty() {
logger.info("locked_file", json!({"path":path,"processes":pids}));
}
logger.unsafe_enter("MoveFileExW", json!({"path": path}));
let move_result = unsafe {
MoveFileExW(
PCWSTR(Utf16Arg::from_path(path).as_ptr()),
PCWSTR::null(),
MOVE_FILE_FLAGS(MOVEFILE_DELAY_UNTIL_REBOOT.0),
)
};
logger.unsafe_exit("MoveFileExW", json!({"ok": move_result.is_ok()}));
move_result?;
Ok(())
}
pub fn set_registry_string(
root: RegistryRoot,
subkey: &str,
name: &str,
value: &str,
logger: &Logger,
) -> Result<(), AppError> {
let mut key = HKEY::default();
logger.unsafe_enter("RegCreateKeyExW", json!({"root":root,"subkey":subkey}));
let create_result = unsafe {
RegCreateKeyExW(
root_hkey(root),
PCWSTR(Utf16Arg::from_str(subkey).as_ptr()),
Some(0),
PWSTR::null(),
REG_OPEN_CREATE_OPTIONS(REG_OPTION_NON_VOLATILE.0),
REG_SAM_FLAGS(KEY_SET_VALUE.0 | KEY_WOW64_64KEY.0),
None,
&mut key,
None,
)
};
logger.unsafe_exit("RegCreateKeyExW", json!({"status": create_result.0}));
win32_ok(create_result, "RegCreateKeyExW")?;
let utf16 = Utf16Arg::from_str(value);
logger.unsafe_enter("RegSetValueExW", json!({"name":name}));
let set_result = unsafe {
RegSetValueExW(
key,
PCWSTR(Utf16Arg::from_str(name).as_ptr()),
Some(0),
REG_VALUE_TYPE(REG_SZ.0),
Some(utf16.as_bytes()),
)
};
logger.unsafe_exit("RegSetValueExW", json!({"status": set_result.0}));
let close_result = close_registry_key(key, logger);
win32_ok(set_result, "RegSetValueExW")?;
close_result?;
Ok(())
}
pub fn delete_registry_tree(
root: RegistryRoot,
subkey: &str,
logger: &Logger,
) -> Result<(), AppError> {
logger.unsafe_enter("RegDeleteTreeW", json!({"root":root,"subkey":subkey}));
let result =
unsafe { RegDeleteTreeW(root_hkey(root), PCWSTR(Utf16Arg::from_str(subkey).as_ptr())) };
logger.unsafe_exit("RegDeleteTreeW", json!({"status": result.0}));
if result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND {
return Ok(());
}
win32_ok(result, "RegDeleteTreeW")
}
pub fn create_shortcut(
shortcut_path: &Path,
target: &Path,
arguments: Option<&str>,
working_directory: Option<&Path>,
description: Option<&str>,
logger: &Logger,
) -> Result<(), AppError> {
logger.unsafe_enter("CoInitializeEx", json!({}));
unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()? };
logger.unsafe_exit("CoInitializeEx", json!({"ok":true}));
let result = (|| -> Result<(), AppError> {
logger.unsafe_enter("CoCreateInstance", json!({"class":"ShellLink"}));
let link: IShellLinkW =
unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)? };
logger.unsafe_exit("CoCreateInstance", json!({"ok":true}));
logger.unsafe_enter("IShellLinkW::SetPath", json!({"target": target}));
unsafe { link.SetPath(PCWSTR(Utf16Arg::from_path(target).as_ptr()))? };
logger.unsafe_exit("IShellLinkW::SetPath", json!({"ok":true}));
if let Some(arguments) = arguments {
logger.unsafe_enter("IShellLinkW::SetArguments", json!({"arguments":arguments}));
unsafe { link.SetArguments(PCWSTR(Utf16Arg::from_str(arguments).as_ptr()))? };
logger.unsafe_exit("IShellLinkW::SetArguments", json!({"ok":true}));
}
if let Some(working_directory) = working_directory {
logger.unsafe_enter(
"IShellLinkW::SetWorkingDirectory",
json!({"working_directory":working_directory}),
);
unsafe {
link.SetWorkingDirectory(PCWSTR(Utf16Arg::from_path(working_directory).as_ptr()))?
};
logger.unsafe_exit("IShellLinkW::SetWorkingDirectory", json!({"ok":true}));
}
if let Some(description) = description {
logger.unsafe_enter(
"IShellLinkW::SetDescription",
json!({"description":description}),
);
unsafe { link.SetDescription(PCWSTR(Utf16Arg::from_str(description).as_ptr()))? };
logger.unsafe_exit("IShellLinkW::SetDescription", json!({"ok":true}));
}
logger.unsafe_enter("Interface::cast<IPersistFile>", json!({}));
let persist: IPersistFile = link.cast()?;
logger.unsafe_exit("Interface::cast<IPersistFile>", json!({"ok":true}));
logger.unsafe_enter("IPersistFile::Save", json!({"path":shortcut_path}));
unsafe { persist.Save(PCWSTR(Utf16Arg::from_path(shortcut_path).as_ptr()), true)? };
logger.unsafe_exit("IPersistFile::Save", json!({"ok":true}));
Ok(())
})();
logger.unsafe_enter("CoUninitialize", json!({}));
unsafe { CoUninitialize() };
logger.unsafe_exit("CoUninitialize", json!({"ok":true}));
result
}
fn get_locking_processes(path: &Path, logger: &Logger) -> Result<Vec<u32>, AppError> {
let mut session = 0u32;
let mut key = [0u16; 33];
logger.unsafe_enter("RmStartSession", json!({}));
let start_result = unsafe { RmStartSession(&mut session, Some(0), PWSTR(key.as_mut_ptr())) };
logger.unsafe_exit(
"RmStartSession",
json!({"status":start_result.0,"session":session}),
);
win32_ok(start_result, "RmStartSession")?;
let file = Utf16Arg::from_path(path);
let resources = [PCWSTR(file.as_ptr())];
logger.unsafe_enter("RmRegisterResources", json!({"path":path}));
let register_result = unsafe { RmRegisterResources(session, Some(&resources), None, None) };
logger.unsafe_exit("RmRegisterResources", json!({"status":register_result.0}));
if let Err(err) = win32_ok(register_result, "RmRegisterResources") {
let _ = end_restart_manager_session(session, logger);
return Err(err);
}
let mut needed = 0u32;
let mut count = 0u32;
let mut reasons = 0u32;
logger.unsafe_enter("RmGetList", json!({"phase":"probe"}));
let probe = unsafe { RmGetList(session, &mut needed, &mut count, None, &mut reasons) };
logger.unsafe_exit(
"RmGetList",
json!({"phase":"probe","status":probe.0,"needed":needed}),
);
if probe != ERROR_SUCCESS && probe != ERROR_MORE_DATA {
let _ = end_restart_manager_session(session, logger);
return win32_ok(probe, "RmGetList").map(|_| Vec::new());
}
if needed == 0 {
end_restart_manager_session(session, logger)?;
return Ok(Vec::new());
}
let mut processes = vec![RM_PROCESS_INFO::default(); needed as usize];
count = needed;
logger.unsafe_enter(
"RmGetList",
json!({"phase":"fetch","capacity":processes.len()}),
);
let fetch = unsafe {
RmGetList(
session,
&mut needed,
&mut count,
Some(processes.as_mut_ptr()),
&mut reasons,
)
};
logger.unsafe_exit(
"RmGetList",
json!({"phase":"fetch","status":fetch.0,"count":count}),
);
end_restart_manager_session(session, logger)?;
win32_ok(fetch, "RmGetList")?;
if count as usize > processes.len() {
return Err(AppError::Message(
"Restart Manager count exceeded allocated buffer".into(),
));
}
Ok(processes
.into_iter()
.take(count as usize)
.map(|p| p.Process.dwProcessId)
.collect())
}
fn end_restart_manager_session(session: u32, logger: &Logger) -> Result<(), AppError> {
logger.unsafe_enter("RmEndSession", json!({"session":session}));
let result = unsafe { RmEndSession(session) };
logger.unsafe_exit("RmEndSession", json!({"status":result.0}));
win32_ok(result, "RmEndSession")
}
fn known_folder(id: &windows::core::GUID, logger: &Logger) -> Result<PathBuf, AppError> {
logger.unsafe_enter("SHGetKnownFolderPath", json!({"folder":format!("{id:?}")}));
let raw = unsafe { SHGetKnownFolderPath(id, KNOWN_FOLDER_FLAG(0), None)? };
logger.unsafe_exit(
"SHGetKnownFolderPath",
json!({"ptr_non_null":!raw.is_null()}),
);
if raw.is_null() {
return Err(AppError::Message(
"SHGetKnownFolderPath returned null".into(),
));
}
let path_result = pwstr_to_path(raw, logger);
logger.unsafe_enter("CoTaskMemFree", json!({}));
unsafe { CoTaskMemFree(Some(raw.0.cast())) };
logger.unsafe_exit("CoTaskMemFree", json!({"ok":true}));
path_result
}
fn pwstr_to_path(raw: PWSTR, logger: &Logger) -> Result<PathBuf, AppError> {
logger.unsafe_enter("PWSTR decode", json!({}));
unsafe {
let mut len = 0usize;
while *raw.0.add(len) != 0 {
len += 1;
}
let slice = std::slice::from_raw_parts(raw.0, len);
let path = String::from_utf16(slice)
.map_err(|_| AppError::Message("Invalid UTF-16 from Win32".into()))?;
logger.unsafe_exit("PWSTR decode", json!({"len":len}));
Ok(PathBuf::from(path))
}
}
fn close_handle(handle: HANDLE, logger: &Logger) -> Result<(), AppError> {
logger.unsafe_enter("CloseHandle", json!({}));
let result = unsafe { CloseHandle(handle) };
logger.unsafe_exit("CloseHandle", json!({"ok":result.is_ok()}));
result?;
Ok(())
}
fn close_registry_key(key: HKEY, logger: &Logger) -> Result<(), AppError> {
logger.unsafe_enter("RegCloseKey", json!({}));
let result = unsafe { RegCloseKey(key) };
logger.unsafe_exit("RegCloseKey", json!({"status":result.0}));
win32_ok(result, "RegCloseKey")
}
fn root_hkey(root: RegistryRoot) -> HKEY {
match root {
RegistryRoot::Hkcu => HKEY_CURRENT_USER,
RegistryRoot::Hklm => HKEY_LOCAL_MACHINE,
}
}
fn win32_ok(status: WIN32_ERROR, operation: &str) -> Result<(), AppError> {
if status == ERROR_SUCCESS {
Ok(())
} else {
Err(AppError::Message(format!(
"{operation} failed with Win32 error {}",
status.0
)))
}
}
struct Utf16Arg {
inner: Vec<u16>,
}
impl Utf16Arg {
fn from_path(path: &Path) -> Self {
Self {
inner: path
.as_os_str()
.encode_wide()
.chain(iter::once(0))
.collect(),
}
}
fn from_str(value: &str) -> Self {
Self {
inner: OsStr::new(value)
.encode_wide()
.chain(iter::once(0))
.collect(),
}
}
fn as_ptr(&self) -> *const u16 {
self.inner.as_ptr()
}
fn as_bytes(&self) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
self.inner.as_ptr().cast::<u8>(),
self.inner.len() * std::mem::size_of::<u16>(),
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(name: &str) -> Self {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"covenant-setup-win-test-{name}-{}-{unique}",
std::process::id()
));
fs::create_dir_all(&path).unwrap();
Self { path }
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn quiet_logger() -> Logger {
Logger {
json: false,
quiet: true,
}
}
fn resolver() -> PathResolver {
PathResolver::with_roots_for_test(vec![
PathBuf::from("C:\\Program Files"),
PathBuf::from("C:\\Program Files (x86)"),
PathBuf::from("D:\\Windows"),
])
}
#[test]
fn requires_admin_matches_subpaths() {
let r = resolver();
assert!(r.requires_admin(Path::new("C:\\Program Files\\App\\bin")));
assert!(r.requires_admin(Path::new("C:\\Program Files (x86)\\Vendor\\app.exe")));
assert!(r.requires_admin(Path::new("D:\\Windows\\System32\\drivers")));
}
#[test]
fn requires_admin_matches_exact_root() {
let r = resolver();
assert!(r.requires_admin(Path::new("C:\\Program Files")));
assert!(r.requires_admin(Path::new("C:\\Program Files\\")));
}
#[test]
fn requires_admin_rejects_sibling_prefix() {
let r = resolver();
assert!(!r.requires_admin(Path::new("C:\\Program Files Custom\\App")));
assert!(!r.requires_admin(Path::new("C:\\Program Files2\\App")));
assert!(!r.requires_admin(Path::new("D:\\WindowsApps\\thing")));
}
#[test]
fn requires_admin_rejects_user_paths() {
let r = resolver();
assert!(!r.requires_admin(Path::new("C:\\Users\\alice\\AppData\\Local\\App")));
assert!(!r.requires_admin(Path::new("D:\\data\\App")));
assert!(!r.requires_admin(Path::new("E:\\")));
}
#[test]
fn requires_admin_is_case_insensitive() {
let r = resolver();
assert!(r.requires_admin(Path::new("c:\\PROGRAM FILES\\App")));
assert!(r.requires_admin(Path::new("D:\\windows\\System32")));
}
#[test]
fn requires_admin_normalizes_forward_slashes() {
let r = resolver();
assert!(r.requires_admin(Path::new("C:/Program Files/App/bin")));
assert!(r.requires_admin(Path::new("D:/Windows/System32")));
}
#[test]
fn requires_admin_handles_non_windows_roots() {
let r = PathResolver::with_roots_for_test(vec![PathBuf::from("E:\\Apps\\Program Files")]);
assert!(r.requires_admin(Path::new("E:\\Apps\\Program Files\\App")));
assert!(!r.requires_admin(Path::new("C:\\Program Files\\App")));
}
#[test]
fn requires_admin_with_empty_roots_returns_false() {
let r = PathResolver::with_roots_for_test(vec![]);
assert!(!r.requires_admin(Path::new("C:\\Program Files\\App")));
}
#[test]
fn create_directory_recursive_creates_nested_directories_and_noops_existing() {
let temp = TestDir::new("create-dir");
let nested = temp.path.join("one").join("two").join("three");
create_directory_recursive(&nested, &quiet_logger()).unwrap();
create_directory_recursive(&nested, &quiet_logger()).unwrap();
assert!(nested.is_dir());
}
#[test]
fn copy_file_copies_bytes_to_destination() {
let temp = TestDir::new("copy-file");
let source = temp.path.join("source.bin");
let destination = temp.path.join("destination.bin");
fs::write(&source, b"copy me").unwrap();
copy_file(&source, &destination, &quiet_logger()).unwrap();
assert_eq!(fs::read(destination).unwrap(), b"copy me");
}
#[test]
fn remove_directory_if_exists_removes_empty_and_defers_nonempty() {
let temp = TestDir::new("remove-dir");
let empty = temp.path.join("empty");
let nonempty = temp.path.join("nonempty");
fs::create_dir_all(&empty).unwrap();
fs::create_dir_all(&nonempty).unwrap();
fs::write(nonempty.join("child.txt"), b"child").unwrap();
remove_directory_if_exists(&empty, &quiet_logger()).unwrap();
remove_directory_if_exists(&nonempty, &quiet_logger()).unwrap();
remove_directory_if_exists(&temp.path.join("missing"), &quiet_logger()).unwrap();
assert!(!empty.exists());
assert!(nonempty.is_dir());
}
#[test]
fn remove_file_with_fallback_deletes_existing_file_and_noops_missing() {
let temp = TestDir::new("remove-file");
let file = temp.path.join("payload.bin");
fs::write(&file, b"delete me").unwrap();
remove_file_with_fallback(&file, &quiet_logger()).unwrap();
remove_file_with_fallback(&file, &quiet_logger()).unwrap();
assert!(!file.exists());
}
#[test]
fn is_elevated_queries_current_process_token() {
let _ = is_elevated(&quiet_logger()).unwrap();
}
#[test]
fn delete_registry_tree_ignores_unique_missing_hkcu_key() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let subkey = format!(
"Software\\CovenantSetupTests\\missing-{}-{unique}",
std::process::id()
);
delete_registry_tree(RegistryRoot::Hkcu, &subkey, &quiet_logger()).unwrap();
}
#[test]
fn create_shortcut_writes_lnk_file_with_optional_fields() {
let temp = TestDir::new("shortcut");
let shortcut = temp.path.join("sample.lnk");
let target = std::env::current_exe().unwrap();
let working_directory = target.parent().unwrap();
create_shortcut(
&shortcut,
&target,
Some("--help"),
Some(working_directory),
Some("Sample shortcut"),
&quiet_logger(),
)
.unwrap();
assert!(shortcut.is_file());
}
#[test]
fn restart_manager_reports_locking_processes_for_unlocked_file() {
let temp = TestDir::new("restart-manager");
let file = temp.path.join("unlocked.txt");
fs::write(&file, b"unlocked").unwrap();
match get_locking_processes(&file, &quiet_logger()) {
Ok(pids) => assert!(pids.iter().all(|pid| *pid > 0)),
Err(err) => assert!(err.to_string().contains("RmStartSession failed")),
}
}
#[test]
fn pwstr_to_path_decodes_valid_utf16_and_rejects_invalid_utf16() {
let mut valid = Utf16Arg::from_str("C:\\Temp").inner;
let path = pwstr_to_path(PWSTR(valid.as_mut_ptr()), &quiet_logger()).unwrap();
assert_eq!(path, PathBuf::from("C:\\Temp"));
let mut invalid = vec![0xD800, 0];
let err = pwstr_to_path(PWSTR(invalid.as_mut_ptr()), &quiet_logger())
.unwrap_err()
.to_string();
assert!(err.contains("Invalid UTF-16"));
}
#[test]
fn win32_ok_accepts_success_and_formats_errors() {
win32_ok(ERROR_SUCCESS, "Example").unwrap();
let err = win32_ok(WIN32_ERROR(5), "Example").unwrap_err().to_string();
assert_eq!(err, "Example failed with Win32 error 5");
}
#[test]
fn quote_passthrough_when_no_special_chars() {
assert_eq!(quote_command_line_arg("install"), "install");
assert_eq!(
quote_command_line_arg("C:\\Apps\\foo.exe"),
"C:\\Apps\\foo.exe"
);
assert_eq!(quote_command_line_arg("--json"), "--json");
}
#[test]
fn quote_wraps_when_contains_space_or_tab() {
assert_eq!(quote_command_line_arg("hello world"), "\"hello world\"");
assert_eq!(quote_command_line_arg("a\tb"), "\"a\tb\"");
assert_eq!(
quote_command_line_arg("C:\\Program Files\\App\\install.toml"),
"\"C:\\Program Files\\App\\install.toml\""
);
}
#[test]
fn quote_escapes_embedded_double_quotes() {
assert_eq!(quote_command_line_arg("a\"b"), "\"a\\\"b\"");
assert_eq!(quote_command_line_arg("\""), "\"\\\"\"");
}
#[test]
fn quote_doubles_trailing_backslashes_before_closing_quote() {
// "C:\Path\" must serialize as "\"C:\\Path\\\\\"" so the parser sees
// the backslashes as literals and the final quote as the terminator.
assert_eq!(
quote_command_line_arg("C:\\Path with space\\"),
"\"C:\\Path with space\\\\\""
);
}
#[test]
fn quote_doubles_backslashes_only_when_followed_by_quote() {
// \\ inside an unquoted-needing arg stays \\ when not before a quote.
assert_eq!(quote_command_line_arg("a\\\\b c"), "\"a\\\\b c\"");
// \\ immediately before a literal quote becomes \\\\\".
assert_eq!(quote_command_line_arg("a\\\\\"b"), "\"a\\\\\\\\\\\"b\"");
}
#[test]
fn quote_emits_explicit_empty_argument() {
assert_eq!(quote_command_line_arg(""), "\"\"");
}
#[test]
fn quote_round_trips_through_argv_rules() {
// Sanity-check that re-parsing the quoted form per the
// CommandLineToArgvW spec recovers the original argument.
for input in [
"simple",
"with space",
"a\"b",
"C:\\Program Files\\app\\bin",
"C:\\Path with space\\",
"trailing\\\\",
"embedded\\\"quote",
"",
] {
let quoted = quote_command_line_arg(input);
let parsed = parse_argv_for_test(&quoted);
assert_eq!(parsed, vec![input.to_string()], "input was {input:?}");
}
}
#[test]
fn utf16_arg_as_bytes_includes_null_terminator() {
let arg = Utf16Arg::from_str("A");
assert_eq!(arg.inner, vec![65, 0]);
assert_eq!(
arg.as_bytes().len(),
arg.inner.len() * std::mem::size_of::<u16>()
);
assert_eq!(arg.as_bytes(), &[65, 0, 0, 0]);
}
// Reference parser following the CommandLineToArgvW algorithm, used only
// to validate the encoder above.
fn parse_argv_for_test(line: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut backslashes = 0usize;
let mut started = false;
let flush_backslashes = |current: &mut String, n: usize| {
for _ in 0..n {
current.push('\\');
}
};
for c in line.chars() {
match c {
'\\' => {
backslashes += 1;
started = true;
}
'"' => {
flush_backslashes(&mut current, backslashes / 2);
if backslashes % 2 == 1 {
current.push('"');
} else {
in_quotes = !in_quotes;
}
backslashes = 0;
started = true;
}
' ' | '\t' if !in_quotes => {
flush_backslashes(&mut current, backslashes);
backslashes = 0;
if started {
args.push(std::mem::take(&mut current));
started = false;
}
}
_ => {
flush_backslashes(&mut current, backslashes);
backslashes = 0;
current.push(c);
started = true;
}
}
}
flush_backslashes(&mut current, backslashes);
if started {
args.push(current);
}
args
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<AssemblyName>Covenant.Setup.Ui.Tests</AssemblyName>
<RootNamespace>Covenant.Setup.Ui.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Covenant.Setup.Ui\Covenant.Setup.Ui.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,142 @@
using System.Text.Json;
using Covenant.Setup.Ui;
using Xunit;
namespace Covenant.Setup.Ui.Tests;
public class InstallerUiFormHelperTests
{
[Fact]
public void BuildErrataJson_uses_provided_errata_when_present()
{
using var doc = JsonDocument.Parse("""{"counter":42,"label":"alpha"}""");
var msg = new UiMessage
{
AppName = "MyApp",
Operation = "install",
Message = "Failed",
Error = "E_FAIL",
Errata = doc.RootElement.Clone()
};
var json = InstallerUiForm.BuildErrataJson(msg);
using var parsed = JsonDocument.Parse(json);
Assert.Equal(JsonValueKind.Object, parsed.RootElement.ValueKind);
Assert.Equal(42, parsed.RootElement.GetProperty("counter").GetInt32());
Assert.Equal("alpha", parsed.RootElement.GetProperty("label").GetString());
Assert.False(parsed.RootElement.TryGetProperty("app_name", out _));
}
[Fact]
public void BuildErrataJson_falls_back_to_synthesized_payload_when_errata_null()
{
var msg = new UiMessage
{
AppName = "MyApp",
Operation = "install",
Message = "Failed",
Error = "E_FAIL",
Errata = null
};
var json = InstallerUiForm.BuildErrataJson(msg);
using var parsed = JsonDocument.Parse(json);
Assert.Equal("MyApp", parsed.RootElement.GetProperty("app_name").GetString());
Assert.Equal("install", parsed.RootElement.GetProperty("operation").GetString());
Assert.Equal("Failed", parsed.RootElement.GetProperty("message").GetString());
Assert.Equal("E_FAIL", parsed.RootElement.GetProperty("error").GetString());
}
[Fact]
public void BuildErrataJson_falls_back_when_errata_is_null_jsonelement()
{
using var doc = JsonDocument.Parse("null");
var msg = new UiMessage
{
AppName = "MyApp",
Operation = "uninstall",
Errata = doc.RootElement.Clone()
};
var json = InstallerUiForm.BuildErrataJson(msg);
using var parsed = JsonDocument.Parse(json);
Assert.Equal("MyApp", parsed.RootElement.GetProperty("app_name").GetString());
Assert.Equal("uninstall", parsed.RootElement.GetProperty("operation").GetString());
}
[Fact]
public void SafeMessageSummary_extracts_known_fields_from_valid_json()
{
const string line = """{"type":"progress","id":"x1","message":"Step 1","extra":"ignored"}""";
var summary = InstallerUiForm.SafeMessageSummary(line);
var json = JsonSerializer.Serialize(summary);
using var parsed = JsonDocument.Parse(json);
Assert.Equal("progress", parsed.RootElement.GetProperty("Type").GetString());
Assert.Equal("x1", parsed.RootElement.GetProperty("Id").GetString());
Assert.Equal("Step 1", parsed.RootElement.GetProperty("Message").GetString());
}
[Fact]
public void SafeMessageSummary_returns_raw_length_for_invalid_json()
{
var summary = InstallerUiForm.SafeMessageSummary("not-json-at-all");
var json = JsonSerializer.Serialize(summary);
using var parsed = JsonDocument.Parse(json);
Assert.Equal("not-json-at-all".Length, parsed.RootElement.GetProperty("RawLength").GetInt32());
Assert.False(parsed.RootElement.TryGetProperty("Type", out _));
}
[Fact]
public void SafeMessageSummary_returns_null_fields_when_known_keys_absent()
{
var summary = InstallerUiForm.SafeMessageSummary("{}");
var json = JsonSerializer.Serialize(summary);
using var parsed = JsonDocument.Parse(json);
Assert.Equal(JsonValueKind.Null, parsed.RootElement.GetProperty("Type").ValueKind);
Assert.Equal(JsonValueKind.Null, parsed.RootElement.GetProperty("Id").ValueKind);
Assert.Equal(JsonValueKind.Null, parsed.RootElement.GetProperty("Message").ValueKind);
}
[Theory]
[InlineData("ok_cancel", MessageBoxButtons.OKCancel)]
[InlineData("yes_no", MessageBoxButtons.YesNo)]
[InlineData("ok", MessageBoxButtons.OK)]
[InlineData(null, MessageBoxButtons.OK)]
[InlineData("unknown", MessageBoxButtons.OK)]
public void MapButtons_handles_known_and_default_values(string? input, MessageBoxButtons expected)
{
Assert.Equal(expected, InstallerUiForm.MapButtons(input));
}
[Theory]
[InlineData("error", MessageBoxIcon.Error)]
[InlineData("warning", MessageBoxIcon.Warning)]
[InlineData("information", MessageBoxIcon.Information)]
[InlineData(null, MessageBoxIcon.Information)]
[InlineData("anything-else", MessageBoxIcon.Information)]
public void MapIcon_handles_known_and_default_values(string? input, MessageBoxIcon expected)
{
Assert.Equal(expected, InstallerUiForm.MapIcon(input));
}
[Theory]
[InlineData(DialogResult.OK, "ok")]
[InlineData(DialogResult.Cancel, "cancel")]
[InlineData(DialogResult.Yes, "yes")]
[InlineData(DialogResult.No, "no")]
[InlineData(DialogResult.None, "none")]
[InlineData(DialogResult.Abort, "none")]
[InlineData(DialogResult.Retry, "none")]
[InlineData(DialogResult.Ignore, "none")]
public void MapDialogResult_maps_to_lowercase_token(DialogResult input, string expected)
{
Assert.Equal(expected, InstallerUiForm.MapDialogResult(input));
}
}
@@ -0,0 +1,54 @@
using System.Text.Json;
using Covenant.Setup.Ui;
using Xunit;
namespace Covenant.Setup.Ui.Tests;
public class ProgramTests
{
[Fact]
public void ReadPipeName_returns_value_following_pipe_flag()
{
var name = Program.ReadPipeName(new[] { "--pipe", "foo" });
Assert.Equal("foo", name);
}
[Fact]
public void ReadPipeName_strips_full_pipe_path_prefix()
{
var name = Program.ReadPipeName(new[] { "--pipe", @"\\.\pipe\foo" });
Assert.Equal("foo", name);
}
copilot-pull-request-reviewer[bot] commented 2026-04-29 01:36:53 +00:00 (Migrated from github.com)
Review

This test uses a full pipe path (\\.\pipe\foo) as the value following --pipe, but the UI process currently passes that string directly into NamedPipeServerStream, which generally expects a pipe name (e.g., foo) rather than a full path. Consider changing the test input to a plain name, and (if you want to accept full paths) add a dedicated test that verifies the normalization/stripping behavior.

This test uses a full pipe path (`\\.\pipe\foo`) as the value following `--pipe`, but the UI process currently passes that string directly into `NamedPipeServerStream`, which generally expects a *pipe name* (e.g., `foo`) rather than a full path. Consider changing the test input to a plain name, and (if you want to accept full paths) add a dedicated test that verifies the normalization/stripping behavior.
[Fact]
public void ReadPipeName_is_case_insensitive_on_flag()
{
var name = Program.ReadPipeName(new[] { "--PIPE", "abc" });
Assert.Equal("abc", name);
}
[Fact]
public void ReadPipeName_finds_flag_among_other_args()
{
var name = Program.ReadPipeName(new[] { "--other", "x", "--pipe", "p1", "--more", "y" });
Assert.Equal("p1", name);
}
[Fact]
public void ReadPipeName_returns_null_when_flag_missing()
{
Assert.Null(Program.ReadPipeName(new[] { "--other", "x" }));
}
[Fact]
public void ReadPipeName_returns_null_when_flag_is_last_arg_with_no_value()
{
Assert.Null(Program.ReadPipeName(new[] { "--pipe" }));
}
[Fact]
public void ReadPipeName_returns_null_for_empty_args()
{
Assert.Null(Program.ReadPipeName(Array.Empty<string>()));
}
}
@@ -0,0 +1,90 @@
using System.Text.Json;
using Covenant.Setup.Ui;
using Xunit;
namespace Covenant.Setup.Ui.Tests;
public class UiMessageJsonTests
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true
};
[Fact]
public void Deserializes_progress_message_with_snake_case_step_fields()
{
const string json = """
{"type":"progress","message":"Copying","current_step":3,"total_steps":10}
""";
var msg = JsonSerializer.Deserialize<UiMessage>(json, Options);
Assert.NotNull(msg);
Assert.Equal("progress", msg!.Type);
Assert.Equal("Copying", msg.Message);
Assert.Equal(3, msg.CurrentStep);
Assert.Equal(10, msg.TotalSteps);
}
[Fact]
public void Deserializes_fail_message_with_app_name_and_errata()
{
const string json = """
{"type":"fail","app_name":"MyApp","operation":"install","message":"Boom","error":"E_FAIL","errata":{"k":1}}
""";
var msg = JsonSerializer.Deserialize<UiMessage>(json, Options);
Assert.NotNull(msg);
Assert.Equal("fail", msg!.Type);
Assert.Equal("MyApp", msg.AppName);
Assert.Equal("install", msg.Operation);
Assert.Equal("Boom", msg.Message);
Assert.Equal("E_FAIL", msg.Error);
Assert.NotNull(msg.Errata);
Assert.Equal(JsonValueKind.Object, msg.Errata!.Value.ValueKind);
}
[Fact]
public void Deserializes_prompt_message_with_buttons_and_icon()
{
const string json = """
{"type":"prompt","id":"p1","title":"Confirm","message":"Reboot now?","buttons":"yes_no","icon":"warning"}
""";
var msg = JsonSerializer.Deserialize<UiMessage>(json, Options);
Assert.NotNull(msg);
Assert.Equal("p1", msg!.Id);
Assert.Equal("yes_no", msg.Buttons);
Assert.Equal("warning", msg.Icon);
}
[Fact]
public void Deserializes_message_with_unknown_type_to_arbitrary_string()
{
var msg = JsonSerializer.Deserialize<UiMessage>("""{"type":"unknown_type"}""", Options);
Assert.Equal("unknown_type", msg!.Type);
}
[Fact]
public void Missing_type_round_trips_as_null()
{
var msg = JsonSerializer.Deserialize<UiMessage>("{}", Options);
Assert.NotNull(msg);
Assert.Null(msg!.Type);
Assert.Null(msg.CurrentStep);
Assert.Null(msg.Errata);
}
[Fact]
public void UiResponse_serializes_with_snake_case_property_names()
{
var response = new UiResponse { Type = "prompt_response", Id = "p1", Result = "yes" };
var json = JsonSerializer.Serialize(response, Options);
Assert.Contains("\"type\":\"prompt_response\"", json);
Assert.Contains("\"id\":\"p1\"", json);
Assert.Contains("\"result\":\"yes\"", json);
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>Covenant.Setup.Ui</AssemblyName>
<RootNamespace>Covenant.Setup.Ui</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Covenant.Setup.Ui.Tests" />
</ItemGroup>
</Project>
+556
View File
@@ -0,0 +1,556 @@
using System.IO.Pipes;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Covenant.Setup.Ui;
internal static class Program
{
[STAThread]
private static void Main(string[] args)
{
var pipeName = ReadPipeName(args);
UiTrace.Write("process_start", new { ProcessId = Environment.ProcessId, PipeName = pipeName });
if (string.IsNullOrWhiteSpace(pipeName))
{
UiTrace.Write("missing_pipe_argument");
MessageBox.Show("Missing named pipe argument.", "covenant-setup", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new InstallerUiForm(pipeName));
}
internal static string? ReadPipeName(string[] args)
{
for (var i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--pipe", StringComparison.OrdinalIgnoreCase))
{
var value = args[i + 1];
const string pipePrefix = @"\\.\pipe\";
if (value.StartsWith(pipePrefix, StringComparison.OrdinalIgnoreCase))
{
value = value[pipePrefix.Length..];
}
return value;
}
copilot-pull-request-reviewer[bot] commented 2026-04-29 01:36:53 +00:00 (Migrated from github.com)
Review

ReadPipeName returns the raw token following --pipe with no validation/normalization. If the caller passes a full named-pipe path like \\.\pipe\foo, NamedPipeServerStream expects just the pipe name and will typically reject names containing path separators. Consider stripping the \\.\pipe\ prefix (or validating and showing a clear error) to make the UI process more robust to how the argument is passed.

`ReadPipeName` returns the raw token following `--pipe` with no validation/normalization. If the caller passes a full named-pipe path like `\\.\pipe\foo`, `NamedPipeServerStream` expects just the pipe name and will typically reject names containing path separators. Consider stripping the `\\.\pipe\` prefix (or validating and showing a clear error) to make the UI process more robust to how the argument is passed.
}
return null;
}
}
internal sealed class InstallerUiForm : Form
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly string _pipeName;
private readonly Label _statusLabel;
private readonly ProgressBar _progressBar;
private readonly TextBox _logBox;
private readonly Button _saveErrataButton;
private readonly Button _closeButton;
private StreamWriter? _writer;
private readonly object _writerLock = new();
private bool _closeRequested;
private string? _errataJson;
public InstallerUiForm(string pipeName)
{
_pipeName = pipeName;
Text = "covenant-setup";
StartPosition = FormStartPosition.CenterScreen;
ClientSize = new Size(720, 420);
MinimumSize = new Size(560, 320);
Font = new Font("Segoe UI", 9F);
_statusLabel = new Label
{
AutoEllipsis = true,
Location = new Point(12, 12),
Size = new Size(ClientSize.Width - 24, 24),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
Text = "Preparing..."
};
_progressBar = new ProgressBar
{
Location = new Point(12, 44),
Size = new Size(ClientSize.Width - 24, 24),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
Minimum = 0,
Maximum = 100
};
_logBox = new TextBox
{
Location = new Point(12, 80),
Size = new Size(ClientSize.Width - 24, ClientSize.Height - 128),
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
Multiline = true,
ScrollBars = ScrollBars.Vertical,
ReadOnly = true,
Font = new Font("Consolas", 9F)
};
_saveErrataButton = new Button
{
Text = "Save error data to local errata.json file?",
Enabled = false,
Visible = false,
Size = new Size(320, 28),
Location = new Point(ClientSize.Width - 432, ClientSize.Height - 40),
Anchor = AnchorStyles.Bottom | AnchorStyles.Right
};
_saveErrataButton.Click += (_, _) => SaveErrata();
_closeButton = new Button
{
Text = "Close",
Enabled = false,
Size = new Size(88, 28),
Location = new Point(ClientSize.Width - 100, ClientSize.Height - 40),
Anchor = AnchorStyles.Bottom | AnchorStyles.Right
};
_closeButton.Click += (_, _) => Close();
Controls.Add(_statusLabel);
Controls.Add(_progressBar);
Controls.Add(_logBox);
Controls.Add(_saveErrataButton);
Controls.Add(_closeButton);
Shown += (_, _) => _ = Task.Run(RunPipeLoop);
FormClosing += (_, args) =>
{
if (!_closeButton.Enabled && !_closeRequested)
{
args.Cancel = true;
}
};
}
private void RunPipeLoop()
{
try
{
UiTrace.Write("pipe_server_create", new { PipeName = _pipeName });
using var pipe = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.None);
UiTrace.Write("pipe_wait_for_connection", new { PipeName = _pipeName });
pipe.WaitForConnection();
UiTrace.Write("pipe_connected", new { PipeName = _pipeName });
using var reader = new StreamReader(pipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
using var writer = new StreamWriter(pipe, new UTF8Encoding(false), bufferSize: 4096, leaveOpen: true)
{
AutoFlush = true,
NewLine = "\n"
};
lock (_writerLock)
{
_writer = writer;
}
string? line;
while ((line = reader.ReadLine()) is not null)
{
UiTrace.Write("pipe_receive", SafeMessageSummary(line));
if (!HandleMessage(line))
{
break;
}
}
}
catch (Exception ex)
{
UiTrace.Write("pipe_error", new { ex.Message, ex.GetType().FullName, ex.StackTrace });
BeginInvokeSafe(() =>
{
AppendLog("UI pipe error: " + ex.Message);
_closeButton.Enabled = true;
});
}
finally
{
lock (_writerLock)
{
_writer = null;
}
UiTrace.Write("pipe_loop_exit");
}
}
private bool HandleMessage(string line)
{
var message = JsonSerializer.Deserialize<UiMessage>(line, JsonOptions);
if (message?.Type is null)
{
return true;
}
switch (message.Type)
{
case "init":
BeginInvokeSafe(() =>
{
Text = message.Title ?? "covenant-setup";
_statusLabel.Text = message.Message ?? Text;
_progressBar.Value = 0;
});
return true;
case "progress":
BeginInvokeSafe(() => ApplyProgress(message));
return true;
case "log":
BeginInvokeSafe(() => AppendLog(message.Message ?? string.Empty));
return true;
case "finish":
UiTrace.Write("finish_message", new { message.Message });
BeginInvokeSafe(() =>
{
_statusLabel.Text = message.Message ?? "Complete";
_progressBar.Value = 100;
_closeButton.Enabled = true;
_closeRequested = true;
Close();
});
return true;
case "fail":
UiTrace.Write("fail_message", new { message.AppName, message.Operation, message.Message, message.Error });
BeginInvokeSafe(() => ApplyFailure(message));
return true;
case "prompt":
UiTrace.Write("prompt_show_requested", new { message.Id, message.Title, message.Buttons, message.Icon });
var result = ShowPrompt(message);
UiTrace.Write("prompt_response", new { message.Id, Result = result });
WriteResponse(new UiResponse
{
Type = "prompt_response",
Id = message.Id,
Result = result
});
return true;
case "close":
UiTrace.Write("close_message");
BeginInvokeSafe(() =>
{
_closeRequested = true;
Close();
});
return false;
default:
return true;
}
}
private void ApplyProgress(UiMessage message)
{
if (!string.IsNullOrWhiteSpace(message.Message))
{
_statusLabel.Text = message.Message;
AppendLog(message.Message);
}
var total = Math.Max(1, message.TotalSteps ?? 1);
var current = Math.Max(0, Math.Min(total, message.CurrentStep ?? 0));
_progressBar.Value = Math.Max(0, Math.Min(100, current * 100 / total));
}
private void ApplyFailure(UiMessage message)
{
var operation = string.IsNullOrWhiteSpace(message.Operation) ? "complete" : message.Operation;
var appName = string.IsNullOrWhiteSpace(message.AppName) ? "unknown" : message.AppName;
var failureMessage = string.IsNullOrWhiteSpace(message.Message)
? $"Error: program {appName} failed to {operation} completely!"
: message.Message;
_statusLabel.Text = failureMessage;
_progressBar.Value = 100;
AppendLog(failureMessage);
if (!string.IsNullOrWhiteSpace(message.Error))
{
AppendLog("Error details: " + message.Error);
}
_errataJson = BuildErrataJson(message);
_saveErrataButton.Enabled = !string.IsNullOrWhiteSpace(_errataJson);
_saveErrataButton.Visible = true;
_closeButton.Enabled = true;
}
private void SaveErrata()
{
if (string.IsNullOrWhiteSpace(_errataJson))
{
return;
}
try
{
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(root))
{
root = Environment.CurrentDirectory;
}
var directory = Path.Combine(root, "CovenantSetup");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "errata.json");
File.WriteAllText(path, _errataJson, new UTF8Encoding(false));
AppendLog("Saved error data to " + path);
MessageBox.Show(this, "Error data saved to " + path, "covenant-setup", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
UiTrace.Write("errata_save_error", new { ex.Message, ex.GetType().FullName, ex.StackTrace });
MessageBox.Show(this, "Unable to save errata.json: " + ex.Message, "covenant-setup", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
internal static string BuildErrataJson(UiMessage message)
{
if (message.Errata is JsonElement errata &&
errata.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null)
{
return JsonSerializer.Serialize(errata, new JsonSerializerOptions { WriteIndented = true });
}
return JsonSerializer.Serialize(new
{
app_name = message.AppName,
operation = message.Operation,
message = message.Message,
error = message.Error
}, new JsonSerializerOptions { WriteIndented = true });
}
private string ShowPrompt(UiMessage message)
{
if (InvokeRequired)
{
return (string)Invoke(new Func<string>(() => ShowPrompt(message)));
}
var buttons = MapButtons(message.Buttons);
var icon = MapIcon(message.Icon);
var result = MessageBox.Show(
this,
message.Message ?? string.Empty,
message.Title ?? "covenant-setup",
buttons,
icon);
UiTrace.Write("prompt_closed", new { message.Id, Result = result.ToString() });
return MapDialogResult(result);
}
internal static MessageBoxButtons MapButtons(string? buttons) => buttons switch
{
"ok_cancel" => MessageBoxButtons.OKCancel,
"yes_no" => MessageBoxButtons.YesNo,
_ => MessageBoxButtons.OK
};
internal static MessageBoxIcon MapIcon(string? icon) => icon switch
{
"error" => MessageBoxIcon.Error,
"warning" => MessageBoxIcon.Warning,
_ => MessageBoxIcon.Information
};
internal static string MapDialogResult(DialogResult result) => result switch
{
DialogResult.OK => "ok",
DialogResult.Cancel => "cancel",
DialogResult.Yes => "yes",
DialogResult.No => "no",
_ => "none"
};
private void WriteResponse(UiResponse response)
{
lock (_writerLock)
{
_writer?.WriteLine(JsonSerializer.Serialize(response, JsonOptions));
UiTrace.Write("pipe_send", new { response.Type, response.Id, response.Result });
}
}
private void BeginInvokeSafe(Action action)
{
if (IsDisposed)
{
return;
}
try
{
BeginInvoke(action);
}
catch (InvalidOperationException)
{
}
}
private void AppendLog(string line)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}
if (_logBox.TextLength > 0)
{
_logBox.AppendText(Environment.NewLine);
}
_logBox.AppendText(line);
_logBox.SelectionStart = _logBox.TextLength;
_logBox.ScrollToCaret();
}
internal static object SafeMessageSummary(string line)
{
try
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
return new
{
Type = root.TryGetProperty("type", out var type) ? type.GetString() : null,
Id = root.TryGetProperty("id", out var id) ? id.GetString() : null,
Message = root.TryGetProperty("message", out var message) ? message.GetString() : null
};
}
catch
{
return new { RawLength = line.Length };
}
}
}
internal static class UiTrace
{
private static readonly object Lock = new();
private static readonly string? TracePath = CreateTracePath();
public static void Write(string phase, object? detail = null)
{
if (TracePath is null)
{
return;
}
try
{
var line = JsonSerializer.Serialize(new
{
time = DateTimeOffset.UtcNow.ToString("o"),
pid = Environment.ProcessId,
process = Process.GetCurrentProcess().ProcessName,
phase,
detail
}) + Environment.NewLine;
lock (Lock)
{
File.AppendAllText(TracePath, line, Encoding.UTF8);
}
}
catch
{
}
}
private static string? CreateTracePath()
{
try
{
var root = Environment.GetEnvironmentVariable("COVENANT_SETUP_TRACE_DIR");
if (string.IsNullOrWhiteSpace(root))
{
return null;
}
Directory.CreateDirectory(root);
return Path.Combine(root, $"csharp-ui-pipe-{Environment.ProcessId}.jsonl");
}
catch
{
return null;
}
}
}
internal sealed class UiMessage
{
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("app_name")]
public string? AppName { get; set; }
[JsonPropertyName("operation")]
public string? Operation { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
[JsonPropertyName("errata")]
public JsonElement? Errata { get; set; }
[JsonPropertyName("current_step")]
public int? CurrentStep { get; set; }
[JsonPropertyName("total_steps")]
public int? TotalSteps { get; set; }
[JsonPropertyName("buttons")]
public string? Buttons { get; set; }
[JsonPropertyName("icon")]
public string? Icon { get; set; }
}
internal sealed class UiResponse
{
[JsonPropertyName("type")]
public string? Type { get; set; }
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("result")]
public string? Result { get; set; }
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Covenant.Setup.Ui.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
+25
View File
@@ -0,0 +1,25 @@
app_name = "Covenant Setup Self Test"
[[directories]]
path = "{LocalAppData}\\CovenantSetupSelfTest"
[[directories]]
path = "{LocalAppData}\\CovenantSetupSelfTest\\bin"
[[files]]
source = "payload\\covenant-setup.exe"
destination = "{LocalAppData}\\CovenantSetupSelfTest\\bin\\covenant-setup.exe"
[[registry]]
key = "HKCU\\Software\\CovenantSetupSelfTest"
name = "InstallRoot"
value = "{LocalAppData}\\CovenantSetupSelfTest"
[[shortcuts]]
path = "{Desktop}\\Covenant Setup Self Test.lnk"
target = "{LocalAppData}\\CovenantSetupSelfTest\\bin\\covenant-setup.exe"
description = "Launch the Covenant Setup self-installed test payload"
[purge]
registry_branches = ["HKCU\\Software\\CovenantSetupSelfTest"]
paths = ["{LocalAppData}\\CovenantSetupSelfTest"]