Feat MVP #1
@@ -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"
|
||||
@@ -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.'
|
||||
@@ -1 +1,9 @@
|
||||
.target/
|
||||
# Added by cargo
|
||||
/dist*
|
||||
/target
|
||||
/.vagrant/
|
||||
/vm/self-test/payload/
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
vm/
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,212 @@
|
||||
# covenant-setup
|
||||
|
|
||||
|
||||
`covenant-setup` is a Windows installer builder and install engine written in Rust.
|
||||
|
||||
## Why a different Windows installer/uninstaller packager?
|
||||
|
||||
Windows has a mess when it comes to managing program lifecycles. Developers can leave files everywhere on install, the OS lets you do ANYTHING if you elevate to admin, and the uninstall process has no idea what files and registry entries were actually created during the install, leaving behind a mess and contributing to registry rot.
|
||||
|
||||
This packager aims to take a different approach by
|
||||
|
||||
- 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.
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
echo GlassBox sample app executed.
|
||||
pause
|
||||
@@ -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`.
|
||||
|
This doc uses 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)
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,949 @@
|
||||
use crate::{AppError, Logger, RegistryRoot};
|
||||
|
`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.
`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(¶ms).as_ptr()),
|
||||
PCWSTR::null(),
|
||||
SW_SHOW,
|
||||
)
|
||||
};
|
||||
let code = result.0 as isize;
|
||||
logger.unsafe_exit("ShellExecuteW", json!({"hinstance": code}));
|
||||
if code <= 32 {
|
||||
return Err(AppError::Message(format!("ShellExecuteW failed: {code}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_directory_recursive(path: &Path, logger: &Logger) -> Result<(), AppError> {
|
||||
if path.as_os_str().is_empty() || path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
if parent != path {
|
||||
create_directory_recursive(parent, logger)?;
|
||||
}
|
||||
}
|
||||
logger.unsafe_enter("CreateDirectoryW", json!({"path": path}));
|
||||
let result = unsafe { CreateDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr()), None) };
|
||||
logger.unsafe_exit("CreateDirectoryW", json!({"ok": result.is_ok()}));
|
||||
if let Err(err) = result {
|
||||
if !path.exists() {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy_file(source: &Path, destination: &Path, logger: &Logger) -> Result<(), AppError> {
|
||||
let source_w = Utf16Arg::from_path(source);
|
||||
let dest_w = Utf16Arg::from_path(destination);
|
||||
logger.unsafe_enter(
|
||||
"CopyFile2",
|
||||
json!({"source":source,"destination":destination}),
|
||||
);
|
||||
let result = unsafe { CopyFile2(PCWSTR(source_w.as_ptr()), PCWSTR(dest_w.as_ptr()), None) };
|
||||
logger.unsafe_exit("CopyFile2", json!({"ok": result.is_ok()}));
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_directory_if_exists(path: &Path, logger: &Logger) -> Result<(), AppError> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
logger.unsafe_enter("RemoveDirectoryW", json!({"path": path}));
|
||||
let result = unsafe { RemoveDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) };
|
||||
logger.unsafe_exit("RemoveDirectoryW", json!({"ok": result.is_ok()}));
|
||||
if let Err(err) = result {
|
||||
if path.is_dir() && fs::read_dir(path)?.next().is_some() {
|
||||
logger.info(
|
||||
"remove_directory_deferred",
|
||||
json!({"path":path,"reason":"not_empty"}),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if path.exists() {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_file_with_fallback(path: &Path, logger: &Logger) -> Result<(), AppError> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
logger.unsafe_enter("DeleteFileW", json!({"path": path}));
|
||||
let delete_result = unsafe { DeleteFileW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) };
|
||||
logger.unsafe_exit("DeleteFileW", json!({"ok": delete_result.is_ok()}));
|
||||
if delete_result.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let pids = get_locking_processes(path, logger).unwrap_or_default();
|
||||
if !pids.is_empty() {
|
||||
logger.info("locked_file", json!({"path":path,"processes":pids}));
|
||||
}
|
||||
logger.unsafe_enter("MoveFileExW", json!({"path": path}));
|
||||
let move_result = unsafe {
|
||||
MoveFileExW(
|
||||
PCWSTR(Utf16Arg::from_path(path).as_ptr()),
|
||||
PCWSTR::null(),
|
||||
MOVE_FILE_FLAGS(MOVEFILE_DELAY_UNTIL_REBOOT.0),
|
||||
)
|
||||
};
|
||||
logger.unsafe_exit("MoveFileExW", json!({"ok": move_result.is_ok()}));
|
||||
move_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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("ed);
|
||||
assert_eq!(parsed, vec![input.to_string()], "input was {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_arg_as_bytes_includes_null_terminator() {
|
||||
let arg = Utf16Arg::from_str("A");
|
||||
assert_eq!(arg.inner, vec![65, 0]);
|
||||
assert_eq!(
|
||||
arg.as_bytes().len(),
|
||||
arg.inner.len() * std::mem::size_of::<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);
|
||||
}
|
||||
|
This test uses a full pipe path ( 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
`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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
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.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.