This commit is contained in:
2026-03-27 21:18:48 -05:00
parent 3614222a7f
commit a5d6c8aa99
13 changed files with 3323 additions and 1 deletions
+3 -1
View File
@@ -1 +1,3 @@
.target/
# Added by cargo
/dist*
/target
+58
View File
@@ -0,0 +1,58 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Covenant-Setup is a Windows installer engine written in Rust. It deterministically tracks all system mutations (files, directories, registry keys, shortcuts, scripts) via a journaling model, enabling exact rollback on uninstall. Windows-only; all system operations use Win32 APIs directly.
## Build & Run Commands
```bash
cargo fmt # Format code
cargo check # Type-check without building
cargo build # Debug build
cargo build --release # Release build
# Package: bundle manifest + payload into a single-file installer EXE
cargo run -- package examples/install.toml --output dist
# Install: apply a manifest directly (or from embedded bundle)
cargo run -- install examples/install.toml --json
# Uninstall: reverse all journaled actions
cargo run -- uninstall examples/journal.json --json
```
No automated test suite exists yet. Manual testing uses the example manifest (`examples/install.toml`).
## Architecture
**Two source files:**
- `src/main.rs` — CLI (clap derive), manifest parsing, install/uninstall/package logic, journaling, UI (TUI/GUI/JSON), elevation handling
- `src/win.rs` — All Win32 FFI isolated here. Every `unsafe` block is bracketed with `logger.unsafe_enter()`/`unsafe_exit()` calls. Contains `PathResolver` for known-folder token resolution, file/directory/registry/shortcut operations, Restart Manager queries, and elevation checks.
**Three operational modes (CLI subcommands):**
1. `package` — Reads TOML manifest, embeds it + payload files into the EXE binary using an append format (JSON payload + u64 size + magic footer `COVENANT_SETUP_BUNDLE_V1`)
2. `install` — Parses manifest (from file or embedded bundle), executes mutations in order, writes `journal.json`, registers in Add/Remove Programs
3. `uninstall` — Reads `journal.json`, reverses actions in LIFO order, handles locked files via Restart Manager + `MoveFileEx` reboot fallback, spawns cleanup helper for self-deletion
**Key types:**
- `InstallManifest` — Declarative TOML contract: directories, files, registry, shortcuts, scripts, purge spec
- `Journal` / `JournalAction` — Serialized record of every mutation for deterministic rollback
- `MutationTracker` trait — Extensibility point (MVP uses `DeclaredTracker`; future: `ObservedTracker` for ETW-based capture)
- `PathResolver` — Resolves `{ProgramFilesX64}`, `{LocalAppData}`, `{Desktop}` tokens via `SHGetKnownFolderPath`
- `Logger` — Dual-mode output: structured JSON (`--json` flag) for IPC or human-readable text
**Elevation:** Manifest/journal is scanned for `HKLM` registry or ProgramFiles paths to determine if admin is needed. Auto-relaunches via `ShellExecuteW` with `runas` when `--elevate` flag is set. Exit code 33 signals elevation required.
**UI modes:** `--headless` forces TUI, `--headed` forces GUI (PowerShell-hosted WinForms), auto-detected from parent process otherwise. JSON mode (`--json`) is for programmatic consumers.
## Conventions
- All Win32 calls go in `src/win.rs`, never in `main.rs`
- UTF-16 conversion uses the `Utf16Arg` wrapper type
- Registry always uses `KEY_WOW64_64KEY` for explicit 64-bit access
- Path tokens (`{ProgramFilesX64}`, etc.) are resolved at runtime, never hardcoded
- Subprocess calls use `CREATE_NO_WINDOW` flag
- Rust edition 2024
Generated
+465
View File
@@ -0,0 +1,465 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "covenant-setup"
version = "0.1.0"
dependencies = [
"clap",
"embed-manifest",
"serde",
"serde_json",
"thiserror",
"toml",
"windows",
]
[[package]]
name = "embed-manifest"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94cdc65b1cf9e871453ce2f86f5aaec24ff2eaa36a1fa3e02e441dddc3613b99"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
dependencies = [
"serde_core",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [
"winnow 1.0.0",
]
[[package]]
name = "toml_writer"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "covenant-setup"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.5.39", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
thiserror = "2.0.17"
toml = "0.9.7"
windows = { version = "0.62.2", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Com",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_RestartManager",
"Win32_System_Registry",
"Win32_System_Threading",
"Win32_UI_Controls",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[build-dependencies]
embed-manifest = "1"
+144
View File
@@ -0,0 +1,144 @@
# covenant-setup
`covenant-setup` is a Windows installer builder and install engine written in Rust.
## Why a different Windows installer/uninstaller packager?
Windows has a mess when it comes to managing program lifecycles. Developers can leave files everywhere on install, the OS lets you do ANYTHING if you elevate to admin, and the uninstall process has no idea what files and registry entries were actually created during the install, leaving behind a mess and contributing to registry rot.
This packager aims to take a different approach by
- Observing all the places a program installs to during installation and during any post-install scripts/operations and then writing a journal.json to the same directory the application installs to. This file is referenced during uninstall to return the machine back to the state it was before the install with any files and registry entries associated with that program.
- Take a "leave the campground better than you found it" approach - this Eagle Scout practices Leave No Trace.
- Taking a "trust but verify model" to program installs and uninstalls, observing program behavior during install and uninstall in order to respect the user.
- Using the `journal.json` as a manifest of everything the program did during the install and post install process.
Its current shape is:
- a packager that takes a developer-authored `install.toml`
- a single-file installer runtime with the app payload embedded into the `.exe`
- an installed uninstaller path that reuses the same Rust engine
## Current Capabilities
- Packages an app from a manifest into a single installer executable
- Installs files, directories, registry values, shortcuts, and post-install scripts
- Journals applied mutations to support deterministic uninstall
- Uninstalls in reverse order and purges declared registry/path namespaces
- Registers the installed app in Windows Installed Apps / Add-Remove Programs
- Creates an installed uninstaller executable in the app root
- Uses Win32 APIs through the `windows` crate with unsafe isolated in [`src/win.rs`](C:\Users\jasonross\workspace\covenant-setup\src\win.rs)
- Logs every unsafe boundary transition
## Packaging Model
The packager command is:
```powershell
cargo run -- package path\to\install.toml --output dist
```
Current output:
- `dist\covenant-setup-installer.exe`
That installer is a single executable. The manifest and payload files are embedded into the binary and extracted to a temporary working directory at runtime.
## Install and Uninstall Model
Direct engine commands:
```powershell
cargo run -- install path\to\install.toml
cargo run -- uninstall path\to\journal.json
```
Packaged installer behavior:
- Running the packaged installer with no subcommand performs install
- The installed app gets:
- `journal.json` in the install root
- `covenant-setup-uninstall.exe` in the install root
- an uninstall registry entry under `...\CurrentVersion\Uninstall\...`
Installed-app uninstall behavior:
- Windows Installed Apps launches the installed uninstaller executable
- The engine removes payload files first
- A cleanup helper from `%TEMP%` removes the running uninstaller after it exits
- If immediate cleanup is impossible, file removal falls back to delete-on-reboot
## UI Behavior
There is now one installer/uninstaller path. UI mode is chosen by context unless explicitly overridden.
Explicit flags:
- `--headless`: force TUI
- `--headed`: force GUI
Current automatic behavior:
- If launched from PowerShell / `pwsh`, uninstall prefers TUI
- If launched from Windows GUI context, install/uninstall prefer GUI
- Otherwise the engine can run without extra UI
### GUI
Current GUI behavior includes:
- native message-box prompts for confirmation and completion
- a progress window with:
- progress bar
- current operation text
- scrolling operations log
- reboot prompt when uninstall requires reboot to finish some cleanup
### TUI
Current TUI behavior includes:
- `Installing {app_name}` or `Uninstalling {app_name}`
- animated walking dots from 0 to 5, cycling every 500ms
- final success / reboot-needed text prompts
## Manifest Scope
The current manifest supports:
- `directories`
- `files`
- `registry`
- `shortcuts`
- `scripts`
- `purge`
The sample manifest lives at [`examples/install.toml`](C:\Users\jasonross\workspace\covenant-setup\examples\install.toml).
## Architecture Notes
- Core engine flow is in [`src/main.rs`](C:\Users\jasonross\workspace\covenant-setup\src\main.rs)
- Windows FFI wrappers are isolated in [`src/win.rs`](C:\Users\jasonross\workspace\covenant-setup\src\win.rs)
- Journaling currently records declared actions through `DeclaredTracker`
- The implementation is Windows-specific
## Current Limitations
- The GUI layer is currently implemented through a PowerShell-hosted WinForms progress window rather than a native Rust GUI framework
- The installer is not yet generating branded/custom themed installer screens
- The manifest schema is still MVP-level and does not cover all production installer concerns
- Script execution logs the script invocation; internal script mutations are not observed beyond declared purge coverage
- The packager currently embeds payload as JSON-appended data; this is functional but not yet optimized for large payloads or tamper-resistance
- No signing, MSI generation, compression, delta updates, or patching pipeline exists yet
- No automated test suite has been added yet for end-to-end installer scenarios
## Verification Status
The codebase currently builds and formats successfully with:
```powershell
cargo fmt
cargo check
```
Interactive GUI/TUI flows have been exercised during development, but there is not yet a formal automated integration harness for packaged installer behavior.
+6
View File
@@ -0,0 +1,6 @@
use embed_manifest::embed_manifest;
fn main() {
embed_manifest(embed_manifest::new_manifest("Comctl32"))
.expect("unable to embed application manifest");
}
+42
View File
@@ -0,0 +1,42 @@
# Covenant-Setup Smoke Test
This example stays in `HKCU` and `{LocalAppData}` so it can be exercised without elevation.
Build single-file installers:
```powershell
cargo run -- package examples/install.toml --output dist
```
This emits:
- `dist\covenant-setup-installer.exe`
The generated installer is a single executable with the manifest and payload embedded into it.
It chooses GUI or TUI mode from context, or you can force one explicitly with `--headed` or `--headless`.
Run install:
```powershell
cargo run -- install examples/install.toml --json
```
Write the journal somewhere explicit:
```powershell
cargo run -- install examples/install.toml --journal examples/journal.json
```
Run uninstall:
```powershell
cargo run -- uninstall examples/journal.json --json
```
Expected effects:
- Creates `%LOCALAPPDATA%\CovenantSetupExample`
- Copies `sample_app.cmd` into the `bin` directory
- Writes `HKCU\Software\CovenantSetupExample\InstallRoot`
- Creates a desktop shortcut
- Runs an inline PowerShell post-install command and records only the script execution in the journal
+35
View File
@@ -0,0 +1,35 @@
app_name = "Covenant-Setup Sample App"
[[directories]]
path = "{LocalAppData}\\CovenantSetupSample"
[[directories]]
path = "{LocalAppData}\\CovenantSetupSample\\bin"
[[files]]
source = "payload\\sample_app.cmd"
destination = "{LocalAppData}\\CovenantSetupSample\\bin\\sample_app.cmd"
[[registry]]
key = "HKCU\\Software\\CovenantSetupSample"
name = "InstallRoot"
value = "{LocalAppData}\\CovenantSetupSample"
[[shortcuts]]
path = "{Desktop}\\Covenant-Setup Sample App.lnk"
target = "{LocalAppData}\\CovenantSetupSample\\bin\\sample_app.cmd"
description = "Launch the Covenant-Setup sample payload"
[[scripts]]
command = "powershell"
args = [
"-ExecutionPolicy",
"Bypass",
"-Command",
"New-Item -ItemType Directory -Path .\\logs -Force | Out-Null; 'post-install script ran' | Set-Content .\\logs\\post_install.txt"
]
working_directory = "{LocalAppData}\\CovenantSetupSample"
[purge]
registry_branches = ["HKCU\\Software\\CovenantSetupSample"]
paths = ["{LocalAppData}\\CovenantSetupSample"]
+3
View File
@@ -0,0 +1,3 @@
$logDir = Join-Path $PWD "logs"
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
"post-install script ran at $(Get-Date -Format o)" | Set-Content -Path (Join-Path $logDir "post_install.txt")
+3
View File
@@ -0,0 +1,3 @@
@echo off
echo GlassBox sample app executed.
pause
+55
View File
@@ -0,0 +1,55 @@
## Project Overview: The "Glass Box" Core Engine (CLI)
**Objective:** Build a native Windows CLI installation packager in Rust that enforces a deterministic, declarative, and fully reversible state model.
**Architecture:** A standalone, high-performance Win64 command-line tool. It reads a declarative manifest, performs system mutations via the Win32 API, and journals every action. It is designed to output structured JSON so a GUI wrapper (like C#) or a CI/CD pipeline can orchestrate it in the future.
---
## MVP Requirements & Feature List
### 1. The Rust CLI Interface & IPC Readiness
* **CLI Framework:** Utilize `clap` for robust argument parsing with standard subcommands (e.g., `glassbox install manifest.toml`, `glassbox uninstall journal.json`).
* **Structured Output Protocol:** The engine must accept a `--json` flag. When active, all standard text logs, progress percentages, and error stack traces must be suppressed and replaced with single-line serialized JSON objects emitted to `stdout`.
* **UAC Handling:** The CLI must detect if it has administrative privileges via token inspection. If elevation is required for target paths, it must gracefully exit with a specific error code or auto-relaunch itself using the `runas` verb.
### 2. Execution & State Management
* **Declarative Contract Parsing:** The engine ingests an `install.toml` manifest defining the exact expected system state (directories to create, binaries to move, registry keys to write, shortcuts to build).
* **API Adherence:** All system calls must utilize the `windows` crate, strictly employing UTF-16 Wide (`W`) Win32 functions.
* **Registry Architecture:** Registry operations must explicitly use the `KEY_WOW64_64KEY` flag to bypass 32-bit redirection, ensuring true 64-bit state management.
* **Dynamic Path Resolution:** Hardcoded paths are forbidden. The engine must use `SHGetKnownFolderPath` (Shell32) to resolve standard directories like `ProgramFilesX64`, `LocalAppData`, and `Desktop`.
### 3. Modular Mutation Tracking (Extensibility Architecture)
* **The `MutationTracker` Trait:** Internal state changes must not be written directly to the journal. Instead, they pass through a Trait/Interface.
* **MVP Implementation:** The initial implementation will be a `DeclaredTracker`. It strictly records the actions the engine performs based on the `install.toml` manifest.
* **Future-Proofing:** This trait design allows an `ObservedTracker` (the ETW Watchdog) to be cleanly injected later to capture out-of-bounds actions performed by sub-processes without changing the core engine logic.
* **Script Execution:** The engine can execute procedural post-install scripts (e.g., PowerShell) via `std::process::Command`, but in the MVP, it will only log the *execution* of the script, not the script's internal mutations.
### 4. Journaling and Uninstallation (Deterministic Rollback)
* **The Transaction Journal:** The engine's applied mutations must be written to a local `journal.json` or `journal.toml` file in the application's root directory upon successful installation.
* **Reverse Execution:** The uninstaller sequence must parse the journal and execute deletion operations in strict reverse chronological order.
* **Locked File Handling:** If a binary is locked by a running process during uninstallation, the engine must leverage the Restart Manager API (`RmStartSession`, `RmGetList`) to identify the locking process, or fallback to `MoveFileEx` with the `MOVEFILE_DELAY_UNTIL_REBOOT` flag.
* **Namespace Purging:** The uninstaller must aggressively delete the entirety of the developer's defined configuration branches (e.g., `HKCU\Software\TargetApp` and `%LOCALAPPDATA%\TargetApp`) to ensure zero shadow residue.
---
## Technical Documentation & Reference Links
These references cover the specific Win32 API boundaries and Rust bindings required for the MVP.
### Rust & Integration Crates
* **`windows` Crate:** The official Microsoft language projection for Win32 APIs. Essential for low-level system access.
* *Documentation:* [https://microsoft.github.io/windows-docs-rs/](https://microsoft.github.io/windows-docs-rs/)
* **`clap` Crate:** The standard for building robust CLI interfaces in Rust.
* *Documentation:* [https://docs.rs/clap/latest/clap/](https://docs.rs/clap/latest/clap/)
* **`serde` & `serde_json` Crates:** For parsing the `install.toml` and formatting the IPC `stdout` streams.
* *Documentation:* [https://serde.rs/](https://serde.rs/)
### Windows System APIs
* **The Windows Registry:** Understanding hives, keys, values, and x64 redirection behavior.
* *Documentation:* [Structure of the Registry - Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/sysinfo/structure-of-the-registry)
* **Restart Manager API:** Necessary for querying which processes are locking files during uninstallation.
* *Documentation:* [Restart Manager - Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/rstmgr/restart-manager-portal)
* **Known Folders (Shell32):** Standardizing where application data is written to avoid hardcoded paths.
* *Documentation:* [KNOWNFOLDERID - Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid)
* **File Management (MoveFileEx):** Crucial for handling delayed deletions upon reboot.
* *Documentation:* [MoveFileExW function - Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw)
+1810
View File
File diff suppressed because it is too large Load Diff
+672
View File
@@ -0,0 +1,672 @@
use crate::{AppError, Logger, RegistryRoot};
use serde_json::json;
use std::ffi::{OsStr, c_void};
use std::fs;
use std::iter;
use std::os::windows::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use windows::Win32::Foundation::{
CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_MORE_DATA, ERROR_SUCCESS, HANDLE, HWND, WIN32_ERROR,
};
use windows::Win32::Security::{GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation};
use windows::Win32::Storage::FileSystem::{
CopyFile2, CreateDirectoryW, DeleteFileW, MOVE_FILE_FLAGS, MOVEFILE_DELAY_UNTIL_REBOOT,
MoveFileExW, RemoveDirectoryW,
};
use windows::Win32::System::Com::{
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx,
CoTaskMemFree, CoUninitialize, IPersistFile,
};
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_SET_VALUE, KEY_WOW64_64KEY,
REG_OPEN_CREATE_OPTIONS, REG_OPTION_NON_VOLATILE, REG_SAM_FLAGS, REG_SZ, REG_VALUE_TYPE,
RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegSetValueExW,
};
use windows::Win32::System::RestartManager::{
RM_PROCESS_INFO, RmEndSession, RmGetList, RmRegisterResources, RmStartSession,
};
use windows::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, OpenProcessToken};
use windows::Win32::UI::Controls::{
TASKDIALOG_COMMON_BUTTON_FLAGS, TDCBF_CANCEL_BUTTON, TDCBF_NO_BUTTON, TDCBF_OK_BUTTON,
TDCBF_YES_BUTTON, TaskDialog,
};
use windows::Win32::UI::Shell::{
FOLDERID_Desktop, FOLDERID_LocalAppData, FOLDERID_ProgramFilesX64, IShellLinkW,
KNOWN_FOLDER_FLAG, SHGetKnownFolderPath, ShellExecuteW, ShellLink,
};
use windows::Win32::UI::WindowsAndMessaging::{IDOK, IDYES, SW_SHOW};
use windows::core::{Interface, PCWSTR, PWSTR, w};
pub struct PathResolver {
pub program_files_x64: PathBuf,
pub local_app_data: PathBuf,
pub desktop: PathBuf,
}
pub fn is_parent_powershell(logger: &Logger) -> Result<bool, AppError> {
let current_pid = unsafe { GetCurrentProcessId() };
logger.unsafe_enter("CreateToolhelp32Snapshot", json!({}));
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)? };
logger.unsafe_exit("CreateToolhelp32Snapshot", json!({"ok": true}));
let result = (|| -> Result<bool, AppError> {
let mut entry = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..Default::default()
};
logger.unsafe_enter("Process32FirstW", json!({}));
let first = unsafe { Process32FirstW(snapshot, &mut entry) };
logger.unsafe_exit("Process32FirstW", json!({"ok": first.is_ok()}));
if first.is_err() {
return Ok(false);
}
let mut parent_pid = None;
loop {
if entry.th32ProcessID == current_pid {
parent_pid = Some(entry.th32ParentProcessID);
break;
}
logger.unsafe_enter("Process32NextW", json!({}));
let next = unsafe { Process32NextW(snapshot, &mut entry) };
logger.unsafe_exit("Process32NextW", json!({"ok": next.is_ok()}));
if next.is_err() {
break;
}
}
let Some(parent_pid) = parent_pid else {
return Ok(false);
};
let mut entry = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..Default::default()
};
logger.unsafe_enter("Process32FirstW", json!({"search_parent": parent_pid}));
let first = unsafe { Process32FirstW(snapshot, &mut entry) };
logger.unsafe_exit("Process32FirstW", json!({"ok": first.is_ok()}));
if first.is_err() {
return Ok(false);
}
loop {
if entry.th32ProcessID == parent_pid {
let exe = wide_array_to_string(&entry.szExeFile);
let exe_lower = exe.to_ascii_lowercase();
return Ok(exe_lower.contains("powershell")
|| exe_lower == "pwsh.exe"
|| exe_lower == "pwsh");
}
logger.unsafe_enter("Process32NextW", json!({"search_parent": parent_pid}));
let next = unsafe { Process32NextW(snapshot, &mut entry) };
logger.unsafe_exit("Process32NextW", json!({"ok": next.is_ok()}));
if next.is_err() {
break;
}
}
Ok(false)
})();
close_handle(snapshot, logger)?;
result
}
impl PathResolver {
pub fn new(logger: &Logger) -> Result<Self, AppError> {
Ok(Self {
program_files_x64: known_folder(&FOLDERID_ProgramFilesX64, logger)?,
local_app_data: known_folder(&FOLDERID_LocalAppData, logger)?,
desktop: known_folder(&FOLDERID_Desktop, logger)?,
})
}
pub fn resolve(&self, input: &str) -> PathBuf {
PathBuf::from(
input
.replace(
"{ProgramFilesX64}",
&self.program_files_x64.to_string_lossy(),
)
.replace("{LocalAppData}", &self.local_app_data.to_string_lossy())
.replace("{Desktop}", &self.desktop.to_string_lossy()),
)
}
}
pub fn is_elevated(logger: &Logger) -> Result<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"}));
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)?;
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).collect::<Vec<_>>().join(" ");
logger.unsafe_enter(
"ShellExecuteW",
json!({"verb":"runas","exe":exe,"params":params}),
);
let result = unsafe {
ShellExecuteW(
Some(HWND::default()),
w!("runas"),
PCWSTR(Utf16Arg::from_path(&exe).as_ptr()),
PCWSTR(Utf16Arg::from_str(&params).as_ptr()),
PCWSTR::null(),
SW_SHOW,
)
};
let code = result.0 as isize;
logger.unsafe_exit("ShellExecuteW", json!({"hinstance": code}));
if code <= 32 {
return Err(AppError::Message(format!("ShellExecuteW failed: {code}")));
}
Ok(())
}
pub fn message_box(
title: &str,
body: &str,
buttons: TASKDIALOG_COMMON_BUTTON_FLAGS,
icon: PCWSTR,
logger: &Logger,
) -> Result<i32, AppError> {
let mut button = 0i32;
let title_w = Utf16Arg::from_str(title);
let body_w = Utf16Arg::from_str(body);
logger.unsafe_enter("TaskDialog", json!({"title":title}));
unsafe {
TaskDialog(
Some(HWND::default()),
None,
PCWSTR(title_w.as_ptr()),
PCWSTR::null(),
PCWSTR(body_w.as_ptr()),
buttons,
icon,
Some(&mut button),
)?
};
logger.unsafe_exit("TaskDialog", json!({"result": button}));
Ok(button)
}
pub fn gui_confirm_install(app_name: &str, logger: &Logger) -> Result<bool, AppError> {
let result = message_box(
"covenant-setup",
&format!("Install {app_name} now?"),
TDCBF_OK_BUTTON | TDCBF_CANCEL_BUTTON,
td_information_icon(),
logger,
)?;
Ok(result == IDOK.0)
}
pub fn gui_report_success(app_name: &str, logger: &Logger) -> Result<(), AppError> {
let _ = message_box(
"covenant-setup",
&format!("{app_name} installation completed successfully"),
TDCBF_OK_BUTTON,
td_information_icon(),
logger,
)?;
Ok(())
}
pub fn gui_report_error(message: &str, logger: &Logger) -> Result<(), AppError> {
let _ = message_box(
"covenant-setup",
message,
TDCBF_OK_BUTTON,
td_error_icon(),
logger,
)?;
Ok(())
}
pub fn gui_report_uninstall_success(app_name: &str, logger: &Logger) -> Result<(), AppError> {
let _ = message_box(
"covenant-setup",
&format!("{app_name} uninstalled successfully!"),
TDCBF_OK_BUTTON,
td_information_icon(),
logger,
)?;
Ok(())
}
pub fn gui_prompt_uninstall_reboot(app_name: &str, logger: &Logger) -> Result<bool, AppError> {
let result = message_box(
"covenant-setup",
&format!(
"{app_name} uninstalled sucessfully! Some files from the program still remain on your computer. To complete removal of these files, restart your computer now."
),
TDCBF_YES_BUTTON | TDCBF_NO_BUTTON,
td_information_icon(),
logger,
)?;
Ok(result == IDYES.0)
}
pub fn create_directory_recursive(path: &Path, logger: &Logger) -> Result<(), AppError> {
if path.as_os_str().is_empty() || path.exists() {
return Ok(());
}
if let Some(parent) = path.parent() {
if parent != path {
create_directory_recursive(parent, logger)?;
}
}
logger.unsafe_enter("CreateDirectoryW", json!({"path": path}));
let result = unsafe { CreateDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr()), None) };
logger.unsafe_exit("CreateDirectoryW", json!({"ok": result.is_ok()}));
if let Err(err) = result {
if !path.exists() {
return Err(err.into());
}
}
Ok(())
}
pub fn copy_file(source: &Path, destination: &Path, logger: &Logger) -> Result<(), AppError> {
let source_w = Utf16Arg::from_path(source);
let dest_w = Utf16Arg::from_path(destination);
logger.unsafe_enter(
"CopyFile2",
json!({"source":source,"destination":destination}),
);
let result = unsafe { CopyFile2(PCWSTR(source_w.as_ptr()), PCWSTR(dest_w.as_ptr()), None) };
logger.unsafe_exit("CopyFile2", json!({"ok": result.is_ok()}));
result?;
Ok(())
}
pub fn remove_directory_if_exists(path: &Path, logger: &Logger) -> Result<(), AppError> {
if !path.exists() {
return Ok(());
}
logger.unsafe_enter("RemoveDirectoryW", json!({"path": path}));
let result = unsafe { RemoveDirectoryW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) };
logger.unsafe_exit("RemoveDirectoryW", json!({"ok": result.is_ok()}));
if let Err(err) = result {
if path.is_dir() && fs::read_dir(path)?.next().is_some() {
logger.info(
"remove_directory_deferred",
json!({"path":path,"reason":"not_empty"}),
);
return Ok(());
}
if path.exists() {
return Err(err.into());
}
}
Ok(())
}
pub fn remove_file_with_fallback(path: &Path, logger: &Logger) -> Result<(), AppError> {
if !path.exists() {
return Ok(());
}
logger.unsafe_enter("DeleteFileW", json!({"path": path}));
let delete_result = unsafe { DeleteFileW(PCWSTR(Utf16Arg::from_path(path).as_ptr())) };
logger.unsafe_exit("DeleteFileW", json!({"ok": delete_result.is_ok()}));
if delete_result.is_ok() {
return Ok(());
}
let pids = get_locking_processes(path, logger).unwrap_or_default();
if !pids.is_empty() {
logger.info("locked_file", json!({"path":path,"processes":pids}));
}
logger.unsafe_enter("MoveFileExW", json!({"path": path}));
let move_result = unsafe {
MoveFileExW(
PCWSTR(Utf16Arg::from_path(path).as_ptr()),
PCWSTR::null(),
MOVE_FILE_FLAGS(MOVEFILE_DELAY_UNTIL_REBOOT.0),
)
};
logger.unsafe_exit("MoveFileExW", json!({"ok": move_result.is_ok()}));
move_result?;
Ok(())
}
pub fn set_registry_string(
root: RegistryRoot,
subkey: &str,
name: &str,
value: &str,
logger: &Logger,
) -> Result<(), AppError> {
let mut key = HKEY::default();
logger.unsafe_enter("RegCreateKeyExW", json!({"root":root,"subkey":subkey}));
let create_result = unsafe {
RegCreateKeyExW(
root_hkey(root),
PCWSTR(Utf16Arg::from_str(subkey).as_ptr()),
Some(0),
PWSTR::null(),
REG_OPEN_CREATE_OPTIONS(REG_OPTION_NON_VOLATILE.0),
REG_SAM_FLAGS(KEY_SET_VALUE.0 | KEY_WOW64_64KEY.0),
None,
&mut key,
None,
)
};
logger.unsafe_exit("RegCreateKeyExW", json!({"status": create_result.0}));
win32_ok(create_result, "RegCreateKeyExW")?;
let utf16 = Utf16Arg::from_str(value);
logger.unsafe_enter("RegSetValueExW", json!({"name":name}));
let set_result = unsafe {
RegSetValueExW(
key,
PCWSTR(Utf16Arg::from_str(name).as_ptr()),
Some(0),
REG_VALUE_TYPE(REG_SZ.0),
Some(utf16.as_bytes()),
)
};
logger.unsafe_exit("RegSetValueExW", json!({"status": set_result.0}));
let close_result = close_registry_key(key, logger);
win32_ok(set_result, "RegSetValueExW")?;
close_result?;
Ok(())
}
pub fn delete_registry_tree(
root: RegistryRoot,
subkey: &str,
logger: &Logger,
) -> Result<(), AppError> {
logger.unsafe_enter("RegDeleteTreeW", json!({"root":root,"subkey":subkey}));
let result =
unsafe { RegDeleteTreeW(root_hkey(root), PCWSTR(Utf16Arg::from_str(subkey).as_ptr())) };
logger.unsafe_exit("RegDeleteTreeW", json!({"status": result.0}));
if result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND {
return Ok(());
}
win32_ok(result, "RegDeleteTreeW")
}
pub fn create_shortcut(
shortcut_path: &Path,
target: &Path,
arguments: Option<&str>,
working_directory: Option<&Path>,
description: Option<&str>,
logger: &Logger,
) -> Result<(), AppError> {
logger.unsafe_enter("CoInitializeEx", json!({}));
unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok()? };
logger.unsafe_exit("CoInitializeEx", json!({"ok":true}));
let result = (|| -> Result<(), AppError> {
logger.unsafe_enter("CoCreateInstance", json!({"class":"ShellLink"}));
let link: IShellLinkW =
unsafe { CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)? };
logger.unsafe_exit("CoCreateInstance", json!({"ok":true}));
logger.unsafe_enter("IShellLinkW::SetPath", json!({"target": target}));
unsafe { link.SetPath(PCWSTR(Utf16Arg::from_path(target).as_ptr()))? };
logger.unsafe_exit("IShellLinkW::SetPath", json!({"ok":true}));
if let Some(arguments) = arguments {
logger.unsafe_enter("IShellLinkW::SetArguments", json!({"arguments":arguments}));
unsafe { link.SetArguments(PCWSTR(Utf16Arg::from_str(arguments).as_ptr()))? };
logger.unsafe_exit("IShellLinkW::SetArguments", json!({"ok":true}));
}
if let Some(working_directory) = working_directory {
logger.unsafe_enter(
"IShellLinkW::SetWorkingDirectory",
json!({"working_directory":working_directory}),
);
unsafe {
link.SetWorkingDirectory(PCWSTR(Utf16Arg::from_path(working_directory).as_ptr()))?
};
logger.unsafe_exit("IShellLinkW::SetWorkingDirectory", json!({"ok":true}));
}
if let Some(description) = description {
logger.unsafe_enter(
"IShellLinkW::SetDescription",
json!({"description":description}),
);
unsafe { link.SetDescription(PCWSTR(Utf16Arg::from_str(description).as_ptr()))? };
logger.unsafe_exit("IShellLinkW::SetDescription", json!({"ok":true}));
}
logger.unsafe_enter("Interface::cast<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 = pwstr_to_path(raw, logger)?;
logger.unsafe_enter("CoTaskMemFree", json!({}));
unsafe { CoTaskMemFree(Some(raw.0.cast())) };
logger.unsafe_exit("CoTaskMemFree", json!({"ok":true}));
Ok(path)
}
fn pwstr_to_path(raw: PWSTR, logger: &Logger) -> Result<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 wide_array_to_string(buffer: &[u16]) -> String {
let len = buffer
.iter()
.position(|value| *value == 0)
.unwrap_or(buffer.len());
String::from_utf16_lossy(&buffer[..len])
}
fn close_handle(handle: HANDLE, logger: &Logger) -> Result<(), AppError> {
logger.unsafe_enter("CloseHandle", json!({}));
let result = unsafe { CloseHandle(handle) };
logger.unsafe_exit("CloseHandle", json!({"ok":result.is_ok()}));
result?;
Ok(())
}
fn close_registry_key(key: HKEY, logger: &Logger) -> Result<(), AppError> {
logger.unsafe_enter("RegCloseKey", json!({}));
let result = unsafe { RegCloseKey(key) };
logger.unsafe_exit("RegCloseKey", json!({"status":result.0}));
win32_ok(result, "RegCloseKey")
}
fn root_hkey(root: RegistryRoot) -> HKEY {
match root {
RegistryRoot::Hkcu => HKEY_CURRENT_USER,
RegistryRoot::Hklm => HKEY_LOCAL_MACHINE,
}
}
fn td_information_icon() -> PCWSTR {
PCWSTR(std::ptr::without_provenance(0xFFFD))
}
fn td_error_icon() -> PCWSTR {
PCWSTR(std::ptr::without_provenance(0xFFFE))
}
fn win32_ok(status: WIN32_ERROR, operation: &str) -> Result<(), AppError> {
if status == ERROR_SUCCESS {
Ok(())
} else {
Err(AppError::Message(format!(
"{operation} failed with Win32 error {}",
status.0
)))
}
}
struct Utf16Arg {
inner: Vec<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>(),
)
}
}
}