From cb3cbec21b0e5c7c6e8fdf94df01ab61d1473b20 Mon Sep 17 00:00:00 2001 From: Seth <80608102+xptea@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:16:09 -0400 Subject: [PATCH] Add verified automatic updates --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 21 +- CHANGELOG.md | 18 +- CMakeLists.txt | 15 +- README.md | 7 + analyze.cmd | 8 +- build.cmd | 13 +- docs/architecture.md | 8 +- src/main.c | 27 ++ src/updater.c | 596 +++++++++++++++++++++++++++++++ src/updater.h | 33 ++ src/version.h | 4 +- tests/test_cli.ps1 | 5 + tests/test_core.c | 50 +++ tests/test_live_update.ps1 | 149 ++++++++ tests/update_smoke.c | 13 + tools/stage-release-metadata.ps1 | 44 +++ 17 files changed, 993 insertions(+), 24 deletions(-) create mode 100644 src/updater.c create mode 100644 src/updater.h create mode 100644 tests/test_live_update.ps1 create mode 100644 tests/update_smoke.c create mode 100644 tools/stage-release-metadata.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46b81d5..73e1b95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,8 +57,8 @@ jobs: .\tests\test_installer.ps1 -InstallerPath (Get-ChildItem .\dist\VGPU-Mon-*-setup.exe -File).FullName - - name: Stage bootstrap installer - run: Copy-Item .\install.ps1 .\dist\install.ps1 + - name: Stage release metadata and checksums + run: .\tools\stage-release-metadata.ps1 - name: Upload release candidates uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -68,5 +68,7 @@ jobs: dist/*.zip dist/*-setup.exe dist/install.ps1 + dist/version.txt + dist/SHA256SUMS.txt if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47d4095..a3b3134 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,14 +49,7 @@ jobs: .\tests\test_installer.ps1 -InstallerPath (Get-ChildItem .\dist\VGPU-Mon-*-setup.exe -File).FullName - name: Write SHA-256 checksums - run: | - Copy-Item .\install.ps1 .\dist\install.ps1 - $assets = Get-ChildItem .\dist\VGPU-Mon-*.zip, .\dist\VGPU-Mon-*-setup.exe, .\dist\install.ps1 -File | Sort-Object Name - $lines = foreach ($asset in $assets) { - $hash = (Get-FileHash $asset.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash $($asset.Name)" - } - $lines | Set-Content .\dist\SHA256SUMS.txt -Encoding ascii + run: .\tools\stage-release-metadata.ps1 - name: Upload verified release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -66,6 +59,7 @@ jobs: dist/*.zip dist/*-setup.exe dist/install.ps1 + dist/version.txt dist/SHA256SUMS.txt if-no-files-found: error @@ -76,5 +70,14 @@ jobs: run: | $zip = (Get-ChildItem .\dist\VGPU-Mon-*.zip -File).FullName $setup = (Get-ChildItem .\dist\VGPU-Mon-*-setup.exe -File).FullName - gh release create $env:GITHUB_REF_NAME $zip $setup .\dist\install.ps1 .\dist\SHA256SUMS.txt ` + gh release create $env:GITHUB_REF_NAME $zip $setup .\dist\install.ps1 ` + .\dist\version.txt .\dist\SHA256SUMS.txt ` --verify-tag --generate-notes --title "VGPU-Mon $env:GITHUB_REF_NAME" + + - name: Test live automatic update lifecycle + if: github.event_name == 'push' + run: | + $version = $env:GITHUB_REF_NAME.TrimStart('v') + .\tests\test_live_update.ps1 ` + -Executable .\build\vgpu-update-smoke.exe ` + -ExpectedVersion $version diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c2df4..2f4a119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable user-facing changes are documented here. This project follows [Seman ## [Unreleased] +## [1.2.0] - 2026-07-13 + +### Added + +- Check a small version manifest on each installed interactive launch and automatically install newer stable releases. +- Verify the exact versioned setup executable with Windows SHA-256 APIs before starting an update. +- Relaunch the monitor with its original interactive options after an update handoff. +- Add `--update`, `--no-update`, and the `VGPU_MON_NO_UPDATE` environment opt-out. + +### Security and quality + +- Keep JSON, one-shot, version, help, and demo commands network-free and deterministic. +- Reject malformed manifests, path-like installer names, HTTPS downgrades, oversized downloads, and checksum mismatches. +- Publish `version.txt` and checksum it alongside every release asset. + ## [1.1.4] - 2026-07-13 ### Fixed @@ -55,7 +70,8 @@ All notable user-facing changes are documented here. This project follows [Seman - Added `/W4 /WX /sdl`, control-flow protection, ASLR/DEP/CET-compatible linker flags, reproducible Release builds, MSVC AddressSanitizer tests, CRT leak checks, static analysis, CodeQL, and pinned CI dependencies. -[Unreleased]: https://github.com/xptea/VGPU-Mon/compare/v1.1.4...HEAD +[Unreleased]: https://github.com/xptea/VGPU-Mon/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/xptea/VGPU-Mon/compare/v1.1.4...v1.2.0 [1.1.4]: https://github.com/xptea/VGPU-Mon/compare/v1.1.3...v1.1.4 [1.1.3]: https://github.com/xptea/VGPU-Mon/compare/v1.1.2...v1.1.3 [1.1.2]: https://github.com/xptea/VGPU-Mon/compare/v1.1.1...v1.1.2 diff --git a/CMakeLists.txt b/CMakeLists.txt index 0c5d5ae..dda5e57 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,23 +32,32 @@ add_executable(vgpu-mon src/nvml_dyn.c src/dxgi_gpu.c src/pdh_gpu.c + src/updater.c src/vgpu.rc) vgpu_configure_target(vgpu-mon) target_link_libraries(vgpu-mon PRIVATE - pdh dxgi dxguid psapi advapi32 shell32 ole32) + pdh dxgi dxguid psapi advapi32 shell32 ole32 winhttp bcrypt) include(CTest) if(BUILD_TESTING) add_executable(vgpu-tests tests/test_core.c src/util.c - src/pdh_gpu.c) + src/pdh_gpu.c + src/updater.c) vgpu_configure_target(vgpu-tests) - target_link_libraries(vgpu-tests PRIVATE pdh psapi advapi32) + target_link_libraries(vgpu-tests PRIVATE pdh psapi advapi32 shell32 winhttp bcrypt) add_executable(vgpu-conpty-tests tests/test_conpty.c) vgpu_configure_target(vgpu-conpty-tests) + add_executable(vgpu-update-smoke + tests/update_smoke.c + src/updater.c) + vgpu_configure_target(vgpu-update-smoke) + target_link_libraries(vgpu-update-smoke PRIVATE + advapi32 shell32 winhttp bcrypt) + add_test(NAME core COMMAND vgpu-tests) add_test(NAME conpty COMMAND vgpu-conpty-tests $) endif() diff --git a/README.md b/README.md index 7c5c1e0..c672225 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ It is a real full-screen terminal application, not a wrapper around `nvidia-smi` - Pause/resume and adjustable 250-5000 ms refresh interval - CSV logging - One-shot human-readable and JSON output for scripts +- Automatic, checksum-verified updates for installed interactive launches - Statically linked MSVC runtime; no NVML SDK or third-party runtime required ## Runtime requirements and compatibility @@ -62,6 +63,10 @@ vgpu --json Existing terminal processes keep their old environment. Uninstalling VGPU-Mon from Windows Settings removes the PATH entry only when the installer originally added it. +Starting with VGPU-Mon 1.2.0, an installed interactive launch checks the latest stable release's small `version.txt` manifest. If a newer version exists, VGPU-Mon downloads that exact versioned installer, verifies its SHA-256 with Windows cryptography, installs it without elevation, and reopens with the same interactive options. Network or GitHub failures do not prevent the monitor from opening. Redirected output and script commands (`--json`, `--once`, `--version`, and `--demo`) never perform an automatic update check. + +Use `vgpu --update` to check immediately. Use `--no-update` for one launch or set `VGPU_MON_NO_UPDATE=1` to disable automatic checks. Portable ZIP copies do not update silently; `vgpu --update` converts a portable copy into the normal per-user installation. + Prefer to inspect scripts before running them? [Read `install.ps1`](install.ps1), or download the versioned installer/portable ZIP manually from Releases. Release assets include `SHA256SUMS.txt`. Windows SmartScreen may warn about community-built releases until the project has a trusted code-signing certificate; always verify the download came from this repository. ## Build and run @@ -185,6 +190,8 @@ Options: --interval MS Sampling interval from 250 to 5000 --log PATH Start CSV logging when interactive mode opens --demo Use deterministic sample data for previews and tests +--update Check for and install an update now +--no-update Skip the automatic update check for this run --help Show help --version Show the version ``` diff --git a/analyze.cmd b/analyze.cmd index 41f5ea5..031aae4 100644 --- a/analyze.cmd +++ b/analyze.cmd @@ -22,18 +22,22 @@ if exist build\obj\analyze rmdir /s /q build\obj\analyze mkdir build\obj\analyze\app mkdir build\obj\analyze\core mkdir build\obj\analyze\conpty +mkdir build\obj\analyze\update set "ANALYZE_FLAGS=/nologo /c /std:c11 /utf-8 /W4 /WX /sdl /analyze /analyze:external- /D_DEBUG" cl %ANALYZE_FLAGS% /Fo:build\obj\analyze\app\ ^ - src\main.c src\util.c src\nvml_dyn.c src\dxgi_gpu.c src\pdh_gpu.c + src\main.c src\util.c src\nvml_dyn.c src\dxgi_gpu.c src\pdh_gpu.c src\updater.c if errorlevel 1 exit /b 1 cl %ANALYZE_FLAGS% /Fo:build\obj\analyze\core\ ^ - tests\test_core.c src\util.c src\pdh_gpu.c + tests\test_core.c src\util.c src\pdh_gpu.c src\updater.c if errorlevel 1 exit /b 1 cl %ANALYZE_FLAGS% /Fo:build\obj\analyze\conpty\ tests\test_conpty.c if errorlevel 1 exit /b 1 +cl %ANALYZE_FLAGS% /Fo:build\obj\analyze\update\ tests\update_smoke.c +if errorlevel 1 exit /b 1 + echo MSVC static analysis passed. diff --git a/build.cmd b/build.cmd index f2e600f..f52beb3 100644 --- a/build.cmd +++ b/build.cmd @@ -44,19 +44,20 @@ if /i "%CONFIG%"=="Sanitize" ( if not exist build\obj\app mkdir build\obj\app if not exist build\obj\tests mkdir build\obj\tests +if not exist build\obj\tests\update mkdir build\obj\tests\update rc /nologo /fo build\obj\app\vgpu.res src\vgpu.rc if errorlevel 1 exit /b %errorlevel% cl %CFLAGS% /Fo:build\obj\app\ /Fd:build\obj\app\compiler.pdb /Fe:build\%OUTPUT_NAME%.exe ^ - src\main.c src\util.c src\nvml_dyn.c src\dxgi_gpu.c src\pdh_gpu.c build\obj\app\vgpu.res ^ - /link %LFLAGS% pdh.lib dxgi.lib dxguid.lib psapi.lib advapi32.lib shell32.lib ole32.lib + src\main.c src\util.c src\nvml_dyn.c src\dxgi_gpu.c src\pdh_gpu.c src\updater.c build\obj\app\vgpu.res ^ + /link %LFLAGS% pdh.lib dxgi.lib dxguid.lib psapi.lib advapi32.lib shell32.lib ole32.lib winhttp.lib bcrypt.lib if errorlevel 1 exit /b %errorlevel% if /i "%RUN_TESTS%"=="test" ( cl %CFLAGS% /Fo:build\obj\tests\ /Fd:build\obj\tests\compiler.pdb /Fe:build\vgpu-tests.exe ^ - tests\test_core.c src\util.c src\pdh_gpu.c ^ - /link %LFLAGS% pdh.lib psapi.lib advapi32.lib + tests\test_core.c src\util.c src\pdh_gpu.c src\updater.c ^ + /link %LFLAGS% pdh.lib psapi.lib advapi32.lib shell32.lib winhttp.lib bcrypt.lib if errorlevel 1 exit /b 1 build\vgpu-tests.exe if errorlevel 1 exit /b 1 @@ -65,6 +66,10 @@ if /i "%RUN_TESTS%"=="test" ( if errorlevel 1 exit /b 1 build\vgpu-conpty-tests.exe build\%OUTPUT_NAME%.exe if errorlevel 1 exit /b 1 + cl %CFLAGS% /Fo:build\obj\tests\update\ /Fd:build\obj\tests\update\compiler.pdb /Fe:build\vgpu-update-smoke.exe ^ + tests\update_smoke.c src\updater.c ^ + /link %LFLAGS% shell32.lib advapi32.lib winhttp.lib bcrypt.lib + if errorlevel 1 exit /b 1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\test_cli.ps1 -Executable build\%OUTPUT_NAME%.exe if errorlevel 1 exit /b 1 ) diff --git a/docs/architecture.md b/docs/architecture.md index 487c11e..8331aba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -VGPU-Mon is a single native Windows process. It has no service, kernel component, injected DLL, background updater, or vendor SDK dependency. +VGPU-Mon is a native Windows application. It has no service, kernel component, injected DLL, persistent background updater, or vendor SDK dependency. An installed interactive launch performs a bounded HTTPS update check before provider initialization; a verified update uses a short-lived PowerShell handoff only after the monitor exits. ## Data flow @@ -10,12 +10,16 @@ VGPU-Mon is a single native Windows process. It has no service, kernel component 4. The sampler normalizes those sources into one `GpuTelemetry` snapshot plus bounded process rows and chart histories. 5. The renderer builds one bounded frame in memory and repaints the visible terminal region in a single write. +The updater reads `version.txt` from the latest stable GitHub Release, validates its strict version, installer, and hash fields, and compares semantic versions. It downloads an exact tag-specific installer to the user temporary directory and hashes it with Windows CNG before starting a transient installer helper. Offline, malformed, oversized, downgraded, or hash-mismatched responses fail closed for the update and fail open for monitoring. Non-interactive output paths do not access the network. + `--demo` replaces steps 1–3 with deterministic values while keeping the real sampling, layout, JSON, input, and rendering paths. It exists for testing and UI previews; it is never presented as hardware telemetry. ## Ownership and cleanup Each provider owns its query/library/COM handles and exposes an idempotent close function. Application cleanup calls all close functions even after partial initialization. Dynamic arrays, command-line conversion buffers, terminal buffers, process handles, tokens, pseudo-console resources, and test capture buffers have explicit paired cleanup. +Updater HTTP, file, registry, hashing, and child-process handles are closed on every path. Partial downloads and helper scripts are deleted after failure or handoff. The updater runs before GPU providers open, so no telemetry handles are inherited by the installer helper. + Debug builds use the MSVC debug heap to report leaks. Sanitizer builds compile the application and native tests with MSVC AddressSanitizer. CI also runs `/analyze` and CodeQL. These checks reduce risk but do not prove the absence of every defect. ## Terminal model @@ -32,4 +36,6 @@ The displayed per-process GPU percentage is the busiest engine for that process, VGPU-Mon runs with the caller’s privileges. Process termination is opt-in, confirmed, and rejects self/system targets. Windows remains the authority for process access. The app does not elevate itself or bypass protected processes. +Automatic updates trust HTTPS certificate validation and the assets attached to this repository's stable GitHub Release. The downloaded setup executable must match the release manifest SHA-256 before execution. Updates remain per-user and do not request elevation. + Process names, GPU UUIDs, CSV files, and JSON snapshots can disclose local system activity. Users should review telemetry before sharing it. diff --git a/src/main.c b/src/main.c index 8fddc3b..8323f2a 100644 --- a/src/main.c +++ b/src/main.c @@ -3,6 +3,7 @@ #include "nvml_dyn.h" #include "dxgi_gpu.h" #include "pdh_gpu.h" +#include "updater.h" #include #include @@ -1409,6 +1410,8 @@ static void print_help(void) { " --interval MS Sampling interval, 250-5000 (default 1000)\n" " --log PATH Start interactive CSV logging immediately\n" " --demo Use deterministic sample data (UI preview/testing)\n" + " --update Check for and install an update now\n" + " --no-update Skip the automatic update check for this run\n" " --help Show this help\n" " --version Show the version\n\n" "Run without options in Windows Terminal for the interactive UI.\n" @@ -1512,6 +1515,7 @@ static int app_main(int argc, char **argv) { } else if (strcmp(argument, "--log") == 0 && argument_index < argc) initial_log = argv[argument_index++]; else if (strcmp(argument, "--demo") == 0) app->demo_mode = true; + else if (strcmp(argument, "--no-update") == 0) { /* handled before app initialization */ } else if (strcmp(argument, "--help") == 0 || strcmp(argument, "-h") == 0) { print_help(); free(app); return 0; } else if (strcmp(argument, "--version") == 0) { printf("VGPU-Mon %s\n", VGPU_VERSION); free(app); return 0; } else { fprintf(stderr, "Unknown or incomplete option: %s\n", argument); print_help(); free(app); return 2; } @@ -1635,6 +1639,29 @@ int wmain(int argc, wchar_t **wide_argv) { _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR); _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF); #endif + bool force_update = updater_is_forced(argc, wide_argv); + DWORD input_mode = 0, output_mode = 0; + bool interactive_console = + GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &input_mode) != FALSE && + GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), &output_mode) != FALSE; + if (force_update || (interactive_console && updater_should_check(argc, wide_argv))) { + VgpuUpdateResult update = updater_check_and_start( + argc, wide_argv, VGPU_VERSION, force_update); + if (update == VGPU_UPDATE_STARTED) return 0; + if (force_update) { + if (update == VGPU_UPDATE_CURRENT) { + printf("VGPU-Mon %s is already up to date.\n", VGPU_VERSION); + return 0; + } + if (update == VGPU_UPDATE_SKIPPED) { + fputs("VGPU-Mon: the update check was skipped.\n", stderr); + } else { + fputs("VGPU-Mon: the update check failed; the current version was not changed.\n", + stderr); + } + return 1; + } + } char **argv = (char **)calloc((size_t)argc, sizeof(*argv)); if (!argv) { fputs("VGPU-Mon: out of memory while reading arguments.\n", stderr); diff --git a/src/updater.c b/src/updater.c new file mode 100644 index 0000000..16bab99 --- /dev/null +++ b/src/updater.c @@ -0,0 +1,596 @@ +#define _CRT_SECURE_NO_WARNINGS +#include "updater.h" + +#include +#include +#include +#include +#include +#include +#include + +#define UPDATE_MANIFEST_URL \ + L"https://github.com/xptea/VGPU-Mon/releases/latest/download/version.txt" +#define UPDATE_MAX_MANIFEST_BYTES 1024U +#define UPDATE_MAX_INSTALLER_BYTES (64U * 1024U * 1024U) +#define UPDATE_HELPER_CAPACITY (256U * 1024U) + +typedef struct { + char *data; + size_t length; + size_t capacity; +} ScriptBuffer; + +static bool wide_argument_equals(const wchar_t *argument, const wchar_t *expected) { + return argument && expected && _wcsicmp(argument, expected) == 0; +} + +bool updater_parse_version(const char *text, unsigned int parts[3]) { + if (!text || !parts || !*text) return false; + const char *cursor = text; + for (size_t index = 0; index < 3; ++index) { + if (*cursor < '0' || *cursor > '9') return false; + if (*cursor == '0' && cursor[1] >= '0' && cursor[1] <= '9') return false; + unsigned long value = 0; + while (*cursor >= '0' && *cursor <= '9') { + unsigned int digit = (unsigned int)(*cursor - '0'); + if (value > (UINT_MAX - digit) / 10UL) return false; + value = value * 10UL + digit; + cursor++; + } + parts[index] = (unsigned int)value; + if (index < 2) { + if (*cursor != '.') return false; + cursor++; + } + } + return *cursor == '\0'; +} + +bool updater_compare_versions(const char *left, const char *right, int *result) { + unsigned int left_parts[3], right_parts[3]; + if (!result || !updater_parse_version(left, left_parts) || + !updater_parse_version(right, right_parts)) return false; + *result = 0; + for (size_t index = 0; index < 3; ++index) { + if (left_parts[index] < right_parts[index]) { + *result = -1; + break; + } + if (left_parts[index] > right_parts[index]) { + *result = 1; + break; + } + } + return true; +} + +static char *trim_ascii(char *text) { + while (*text == ' ' || *text == '\t') text++; + size_t length = strlen(text); + while (length && (text[length - 1] == ' ' || text[length - 1] == '\t' || + text[length - 1] == '\r')) { + text[--length] = '\0'; + } + return text; +} + +static int hex_digit(char value) { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + if (value >= 'A' && value <= 'F') return value - 'A' + 10; + return -1; +} + +bool updater_parse_manifest(const char *text, VgpuUpdateManifest *manifest) { + if (!text || !manifest) return false; + size_t length = strnlen_s(text, UPDATE_MAX_MANIFEST_BYTES + 1U); + if (length == 0 || length > UPDATE_MAX_MANIFEST_BYTES) return false; + + char copy[UPDATE_MAX_MANIFEST_BYTES + 1U]; + memcpy(copy, text, length + 1U); + memset(manifest, 0, sizeof(*manifest)); + bool have_version = false, have_installer = false, have_hash = false; + + char *context = NULL; + for (char *line = strtok_s(copy, "\n", &context); line; + line = strtok_s(NULL, "\n", &context)) { + char *trimmed_line = trim_ascii(line); + if (!*trimmed_line) continue; + char *separator = strchr(trimmed_line, '='); + if (!separator) return false; + *separator = '\0'; + char *key = trim_ascii(trimmed_line); + char *value = trim_ascii(separator + 1); + + if (strcmp(key, "version") == 0) { + unsigned int parts[3]; + if (have_version || !updater_parse_version(value, parts) || + strlen(value) >= sizeof(manifest->version)) return false; + strcpy_s(manifest->version, sizeof(manifest->version), value); + have_version = true; + } else if (strcmp(key, "installer") == 0) { + if (have_installer || !*value || + strlen(value) >= sizeof(manifest->installer_name)) return false; + strcpy_s(manifest->installer_name, sizeof(manifest->installer_name), value); + have_installer = true; + } else if (strcmp(key, "sha256") == 0) { + if (have_hash || strlen(value) != 64U) return false; + for (size_t index = 0; index < 32; ++index) { + int high = hex_digit(value[index * 2U]); + int low = hex_digit(value[index * 2U + 1U]); + if (high < 0 || low < 0) return false; + manifest->sha256[index] = (unsigned char)((high << 4) | low); + } + have_hash = true; + } else { + return false; + } + } + + if (!have_version || !have_installer || !have_hash) return false; + char expected[VGPU_UPDATE_INSTALLER_CAP]; + int written = _snprintf_s(expected, sizeof(expected), _TRUNCATE, + "VGPU-Mon-%s-setup.exe", manifest->version); + return written > 0 && strcmp(expected, manifest->installer_name) == 0; +} + +bool updater_is_forced(int argc, wchar_t **argv) { + for (int index = 1; index < argc; ++index) { + if (wide_argument_equals(argv[index], L"--update")) return true; + } + return false; +} + +bool updater_should_check(int argc, wchar_t **argv) { + wchar_t disabled[8]; + DWORD disabled_length = GetEnvironmentVariableW( + L"VGPU_MON_NO_UPDATE", disabled, (DWORD)_countof(disabled)); + if (disabled_length >= _countof(disabled)) return false; + if (disabled_length > 0 && !wide_argument_equals(disabled, L"0")) return false; + + static const wchar_t *skip_arguments[] = { + L"--no-update", L"--once", L"--json", L"--version", + L"--help", L"-h", L"--demo" + }; + for (int index = 1; index < argc; ++index) { + for (size_t skip = 0; skip < _countof(skip_arguments); ++skip) { + if (wide_argument_equals(argv[index], skip_arguments[skip])) return false; + } + } + return true; +} + +static bool get_http_components(const wchar_t *url, wchar_t *host, size_t host_cap, + wchar_t *object, size_t object_cap, + INTERNET_PORT *port) { + URL_COMPONENTS parts; + memset(&parts, 0, sizeof(parts)); + parts.dwStructSize = sizeof(parts); + parts.dwHostNameLength = (DWORD)-1L; + parts.dwUrlPathLength = (DWORD)-1L; + parts.dwExtraInfoLength = (DWORD)-1L; + if (!WinHttpCrackUrl(url, 0, 0, &parts) || parts.nScheme != INTERNET_SCHEME_HTTPS || + !parts.lpszHostName || parts.dwHostNameLength == 0 || + parts.dwHostNameLength >= host_cap) return false; + wcsncpy_s(host, host_cap, parts.lpszHostName, parts.dwHostNameLength); + + size_t path_length = parts.dwUrlPathLength; + size_t extra_length = parts.dwExtraInfoLength; + if (path_length + extra_length + 1U > object_cap) return false; + if (path_length) wcsncpy_s(object, object_cap, parts.lpszUrlPath, path_length); + else wcscpy_s(object, object_cap, L"/"); + if (extra_length) { + wcsncat_s(object, object_cap, parts.lpszExtraInfo, extra_length); + } + *port = parts.nPort; + return true; +} + +static HINTERNET open_http_request(const wchar_t *url, HINTERNET *session, + HINTERNET *connection) { + wchar_t host[256], object[1024]; + INTERNET_PORT port = INTERNET_DEFAULT_HTTPS_PORT; + if (!get_http_components(url, host, _countof(host), object, _countof(object), &port)) + return NULL; + + *session = WinHttpOpen(L"VGPU-Mon Updater", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!*session) return NULL; + WinHttpSetTimeouts(*session, 1000, 1500, 1500, 4000); + *connection = WinHttpConnect(*session, host, port, 0); + if (!*connection) return NULL; + HINTERNET request = WinHttpOpenRequest(*connection, L"GET", object, NULL, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE); + if (!request) return NULL; + DWORD redirect_policy = WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP; + WinHttpSetOption(request, WINHTTP_OPTION_REDIRECT_POLICY, + &redirect_policy, sizeof(redirect_policy)); + WinHttpAddRequestHeaders(request, L"Cache-Control: no-cache\r\n", (DWORD)-1L, + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); + return request; +} + +static void close_http_handles(HINTERNET request, HINTERNET connection, + HINTERNET session) { + if (request) WinHttpCloseHandle(request); + if (connection) WinHttpCloseHandle(connection); + if (session) WinHttpCloseHandle(session); +} + +static bool begin_http_get(HINTERNET request) { + if (!WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + WINHTTP_NO_REQUEST_DATA, 0, 0, 0) || + !WinHttpReceiveResponse(request, NULL)) return false; + DWORD status = 0, status_size = sizeof(status); + return WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | + WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, + &status, &status_size, WINHTTP_NO_HEADER_INDEX) && status == 200; +} + +static bool http_get_memory(const wchar_t *url, char *buffer, size_t capacity, + size_t *received) { + if (!url || !buffer || capacity < 2 || capacity > MAXDWORD || !received) return false; + HINTERNET session = NULL, connection = NULL; + HINTERNET request = open_http_request(url, &session, &connection); + bool success = false; + size_t total = 0; + if (!request || !begin_http_get(request)) goto cleanup; + for (;;) { + DWORD available = 0; + if (!WinHttpQueryDataAvailable(request, &available)) goto cleanup; + if (available == 0) break; + if ((size_t)available > capacity - total - 1U) goto cleanup; + DWORD read = 0; + if (!WinHttpReadData(request, buffer + total, available, &read) || read == 0) + goto cleanup; + total += read; + } + if (memchr(buffer, '\0', total) != NULL) goto cleanup; + buffer[total] = '\0'; + *received = total; + success = total > 0; + +cleanup: + close_http_handles(request, connection, session); + return success; +} + +static bool http_download_file(const wchar_t *url, const wchar_t *path) { + HINTERNET session = NULL, connection = NULL; + HINTERNET request = open_http_request(url, &session, &connection); + HANDLE file = INVALID_HANDLE_VALUE; + bool success = false; + unsigned long long total = 0; + if (!request || !begin_http_get(request)) goto cleanup; + file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, + FILE_ATTRIBUTE_TEMPORARY, NULL); + if (file == INVALID_HANDLE_VALUE) goto cleanup; + + for (;;) { + DWORD available = 0; + if (!WinHttpQueryDataAvailable(request, &available)) goto cleanup; + if (available == 0) break; + if (total + available > UPDATE_MAX_INSTALLER_BYTES) goto cleanup; + unsigned char buffer[8U * 1024U]; + while (available) { + DWORD request_bytes = available > (DWORD)sizeof(buffer) ? + (DWORD)sizeof(buffer) : available; + DWORD read = 0, written = 0; + if (!WinHttpReadData(request, buffer, request_bytes, &read) || read == 0 || + !WriteFile(file, buffer, read, &written, NULL) || written != read) goto cleanup; + available -= read; + total += read; + } + } + success = total > 0 && FlushFileBuffers(file); + +cleanup: + if (file != INVALID_HANDLE_VALUE) CloseHandle(file); + close_http_handles(request, connection, session); + if (!success) DeleteFileW(path); + return success; +} + +static bool sha256_file(const wchar_t *path, unsigned char digest[32]) { + BCRYPT_ALG_HANDLE algorithm = NULL; + BCRYPT_HASH_HANDLE hash = NULL; + HANDLE file = INVALID_HANDLE_VALUE; + unsigned char *object = NULL; + DWORD object_size = 0, result_size = 0; + bool success = false; + + if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, NULL, 0) < 0 || + BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, (PUCHAR)&object_size, + sizeof(object_size), &result_size, 0) < 0 || + object_size == 0) goto cleanup; + object = (unsigned char *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, object_size); + if (!object || BCryptCreateHash(algorithm, &hash, object, object_size, + NULL, 0, 0) < 0) goto cleanup; + file = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, + FILE_FLAG_SEQUENTIAL_SCAN, NULL); + if (file == INVALID_HANDLE_VALUE) goto cleanup; + for (;;) { + unsigned char buffer[8U * 1024U]; + DWORD read = 0; + if (!ReadFile(file, buffer, sizeof(buffer), &read, NULL)) goto cleanup; + if (read == 0) break; + if (BCryptHashData(hash, buffer, read, 0) < 0) goto cleanup; + } + success = BCryptFinishHash(hash, digest, 32, 0) >= 0; + +cleanup: + if (file != INVALID_HANDLE_VALUE) CloseHandle(file); + if (hash) BCryptDestroyHash(hash); + if (object) { + SecureZeroMemory(object, object_size); + HeapFree(GetProcessHeap(), 0, object); + } + if (algorithm) BCryptCloseAlgorithmProvider(algorithm, 0); + return success; +} + +static bool make_temporary_path(const wchar_t *extension, wchar_t *path, + size_t capacity) { + wchar_t temporary[MAX_PATH]; + DWORD length = GetTempPathW((DWORD)_countof(temporary), temporary); + if (length == 0 || length >= _countof(temporary)) return false; + for (unsigned int attempt = 0; attempt < 16; ++attempt) { + int written = _snwprintf_s( + path, capacity, _TRUNCATE, L"%lsVGPU-Mon-%lu-%llu-%u%ls", temporary, + (unsigned long)GetCurrentProcessId(), (unsigned long long)GetTickCount64(), + attempt, extension); + if (written <= 0) return false; + HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_NEW, + FILE_ATTRIBUTE_TEMPORARY, NULL); + if (file != INVALID_HANDLE_VALUE) { + CloseHandle(file); + return true; + } + if (GetLastError() != ERROR_FILE_EXISTS) return false; + } + return false; +} + +static bool get_current_executable(wchar_t *path, size_t capacity) { + DWORD length = GetModuleFileNameW(NULL, path, (DWORD)capacity); + return length > 0 && length < capacity; +} + +static bool get_installed_directory(wchar_t *path, size_t capacity) { + DWORD bytes = (DWORD)(capacity * sizeof(wchar_t)); + LSTATUS status = RegGetValueW(HKEY_CURRENT_USER, L"Software\\VGPU-Mon", + L"PathEntry", RRF_RT_REG_SZ | RRF_NOEXPAND, + NULL, path, &bytes); + if (status != ERROR_SUCCESS || !path[0]) return false; + size_t length = wcslen(path); + while (length > 3 && (path[length - 1] == L'\\' || path[length - 1] == L'/')) + path[--length] = L'\0'; + return true; +} + +static bool executable_is_installed(const wchar_t *executable) { + wchar_t directory[MAX_PATH]; + if (!get_installed_directory(directory, _countof(directory))) return false; + wchar_t current[MAX_PATH]; + wcscpy_s(current, _countof(current), executable); + wchar_t *separator = wcsrchr(current, L'\\'); + if (!separator) return false; + *separator = L'\0'; + return _wcsicmp(current, directory) == 0; +} + +static bool get_default_installed_executable(wchar_t *path, size_t capacity) { + wchar_t local_app_data[MAX_PATH]; + if (SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA | CSIDL_FLAG_CREATE, NULL, + SHGFP_TYPE_CURRENT, local_app_data) != S_OK) return false; + return _snwprintf_s(path, capacity, _TRUNCATE, + L"%ls\\Programs\\VGPU-Mon\\vgpu.exe", local_app_data) > 0; +} + +static bool script_append(ScriptBuffer *buffer, const char *text) { + size_t length = strlen(text); + if (!buffer || !buffer->data || length > buffer->capacity - buffer->length - 1U) + return false; + memcpy(buffer->data + buffer->length, text, length); + buffer->length += length; + buffer->data[buffer->length] = '\0'; + return true; +} + +static bool script_append_quoted_wide(ScriptBuffer *buffer, const wchar_t *text) { + if (!script_append(buffer, "'")) return false; + int required = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, -1, + NULL, 0, NULL, NULL); + if (required <= 0) return false; + char *utf8 = (char *)HeapAlloc(GetProcessHeap(), 0, (size_t)required); + if (!utf8) return false; + bool success = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, text, -1, + utf8, required, NULL, NULL) > 0; + if (success) { + for (char *cursor = utf8; *cursor; ++cursor) { + if (*cursor == '\'') success = script_append(buffer, "''"); + else { + char value[2] = {*cursor, '\0'}; + success = script_append(buffer, value); + } + if (!success) break; + } + } + HeapFree(GetProcessHeap(), 0, utf8); + return success && script_append(buffer, "'"); +} + +static bool write_helper_script(const wchar_t *path, int argc, wchar_t **argv) { + ScriptBuffer script; + memset(&script, 0, sizeof(script)); + script.capacity = UPDATE_HELPER_CAPACITY; + script.data = (char *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, script.capacity); + if (!script.data) return false; + bool success = script_append(&script, + "[CmdletBinding()]\r\n" + "param([Parameter(Mandatory=$true)][string]$InstallerPath," + "[Parameter(Mandatory=$true)][string]$RelaunchPath)\r\n" + "$ErrorActionPreference = 'Stop'\r\n" + "$relaunchArguments = @('--no-update'"); + if (success && updater_is_forced(argc, argv)) { + success = script_append(&script, ",'--version'"); + } + for (int index = 1; success && index < argc; ++index) { + if (wide_argument_equals(argv[index], L"--update") || + wide_argument_equals(argv[index], L"--no-update")) continue; + success = script_append(&script, ",") && + script_append_quoted_wide(&script, argv[index]); + } + success = success && script_append(&script, + ")\r\n" + "$updateError = $null\r\n" + "try {\r\n" + " Start-Sleep -Milliseconds 750\r\n" + " $process = Start-Process -FilePath $InstallerPath -ArgumentList " + "@('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-') " + "-Wait -PassThru -WindowStyle Hidden\r\n" + " if ($process.ExitCode -ne 0) { throw \"Setup exited with code " + "$($process.ExitCode).\" }\r\n" + "}\r\n" + "catch { $updateError = $_.Exception.Message }\r\n" + "finally {\r\n" + " Remove-Item -LiteralPath $InstallerPath -Force -ErrorAction SilentlyContinue\r\n" + " Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue\r\n" + "}\r\n" + "if ($updateError) { Write-Warning \"VGPU-Mon update failed: $updateError\" }\r\n" + "if (Test-Path -LiteralPath $RelaunchPath) {\r\n" + " & $RelaunchPath @relaunchArguments\r\n" + " exit $LASTEXITCODE\r\n" + "}\r\n" + "exit 1\r\n"); + + HANDLE file = INVALID_HANDLE_VALUE; + if (success) { + file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, + FILE_ATTRIBUTE_TEMPORARY, NULL); + if (file == INVALID_HANDLE_VALUE) success = false; + } + if (success) { + static const unsigned char bom[] = {0xEF, 0xBB, 0xBF}; + DWORD written = 0; + success = WriteFile(file, bom, sizeof(bom), &written, NULL) && + written == sizeof(bom) && + WriteFile(file, script.data, (DWORD)script.length, &written, NULL) && + written == (DWORD)script.length && FlushFileBuffers(file); + } + if (file != INVALID_HANDLE_VALUE) CloseHandle(file); + SecureZeroMemory(script.data, script.capacity); + HeapFree(GetProcessHeap(), 0, script.data); + if (!success) DeleteFileW(path); + return success; +} + +static bool launch_helper(const wchar_t *helper, const wchar_t *installer, + const wchar_t *relaunch) { + wchar_t system_directory[MAX_PATH], powershell[MAX_PATH]; + const size_t command_capacity = 32768U; + wchar_t *command = (wchar_t *)HeapAlloc( + GetProcessHeap(), HEAP_ZERO_MEMORY, command_capacity * sizeof(wchar_t)); + if (!command) return false; + UINT length = GetSystemDirectoryW(system_directory, (UINT)_countof(system_directory)); + if (length == 0 || length >= _countof(system_directory) || + _snwprintf_s(powershell, _countof(powershell), _TRUNCATE, + L"%ls\\WindowsPowerShell\\v1.0\\powershell.exe", + system_directory) <= 0 || + _snwprintf_s(command, command_capacity, _TRUNCATE, + L"\"%ls\" -NoLogo -NoProfile -ExecutionPolicy Bypass " + L"-File \"%ls\" -InstallerPath \"%ls\" -RelaunchPath \"%ls\"", + powershell, helper, installer, relaunch) <= 0) { + HeapFree(GetProcessHeap(), 0, command); + return false; + } + + STARTUPINFOW startup; + PROCESS_INFORMATION process; + memset(&startup, 0, sizeof(startup)); + memset(&process, 0, sizeof(process)); + startup.cb = sizeof(startup); + if (!CreateProcessW(powershell, command, NULL, NULL, TRUE, + CREATE_UNICODE_ENVIRONMENT, NULL, NULL, &startup, &process)) { + HeapFree(GetProcessHeap(), 0, command); + return false; + } + HeapFree(GetProcessHeap(), 0, command); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return true; +} + +VgpuUpdateResult updater_check_and_start(int argc, wchar_t **argv, + const char *current_version, + bool force) { + wchar_t current_executable[MAX_PATH], relaunch_path[MAX_PATH]; + if (!get_current_executable(current_executable, _countof(current_executable))) + return VGPU_UPDATE_ERROR; + bool installed = executable_is_installed(current_executable); + if (!installed && !force) return VGPU_UPDATE_SKIPPED; + if (installed) wcscpy_s(relaunch_path, _countof(relaunch_path), current_executable); + else if (!get_default_installed_executable(relaunch_path, _countof(relaunch_path))) + return VGPU_UPDATE_ERROR; + + char manifest_text[UPDATE_MAX_MANIFEST_BYTES + 1U]; + size_t manifest_size = 0; + VgpuUpdateManifest manifest; + int comparison = 0; + if (!http_get_memory(UPDATE_MANIFEST_URL, manifest_text, + sizeof(manifest_text), &manifest_size) || manifest_size == 0 || + !updater_parse_manifest(manifest_text, &manifest) || + !updater_compare_versions(current_version, manifest.version, &comparison)) + return VGPU_UPDATE_ERROR; + if (comparison >= 0) return VGPU_UPDATE_CURRENT; + + wchar_t installer_url[1024], installer_path[MAX_PATH], helper_path[MAX_PATH]; + wchar_t version_wide[VGPU_UPDATE_VERSION_CAP]; + wchar_t installer_name_wide[VGPU_UPDATE_INSTALLER_CAP]; + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, manifest.version, -1, + version_wide, (int)_countof(version_wide)) || + !MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, manifest.installer_name, -1, + installer_name_wide, (int)_countof(installer_name_wide)) || + _snwprintf_s(installer_url, _countof(installer_url), _TRUNCATE, + L"https://github.com/xptea/VGPU-Mon/releases/download/v%ls/%ls", + version_wide, installer_name_wide) <= 0) + return VGPU_UPDATE_ERROR; + if (!make_temporary_path(L".exe", installer_path, _countof(installer_path))) + return VGPU_UPDATE_ERROR; + if (!make_temporary_path(L".ps1", helper_path, _countof(helper_path))) { + DeleteFileW(installer_path); + return VGPU_UPDATE_ERROR; + } + + bool installer_ready = false, helper_ready = false; + unsigned char actual_hash[32]; + if (force) { + printf("VGPU-Mon %s is available; downloading verified update...\n", + manifest.version); + fflush(stdout); + } + if (!http_download_file(installer_url, installer_path) || + !sha256_file(installer_path, actual_hash) || + memcmp(actual_hash, manifest.sha256, sizeof(actual_hash)) != 0) goto cleanup; + installer_ready = true; + if (!write_helper_script(helper_path, argc, argv)) goto cleanup; + helper_ready = true; + + printf("Updating VGPU-Mon %s to %s; the monitor will reopen automatically...\n", + current_version, manifest.version); + fflush(stdout); + if (!launch_helper(helper_path, installer_path, relaunch_path)) goto cleanup; + SecureZeroMemory(actual_hash, sizeof(actual_hash)); + return VGPU_UPDATE_STARTED; + +cleanup: + SecureZeroMemory(actual_hash, sizeof(actual_hash)); + if (installer_ready || GetFileAttributesW(installer_path) != INVALID_FILE_ATTRIBUTES) + DeleteFileW(installer_path); + if (helper_ready || GetFileAttributesW(helper_path) != INVALID_FILE_ATTRIBUTES) + DeleteFileW(helper_path); + return VGPU_UPDATE_ERROR; +} diff --git a/src/updater.h b/src/updater.h new file mode 100644 index 0000000..c6eb153 --- /dev/null +++ b/src/updater.h @@ -0,0 +1,33 @@ +#ifndef VGPU_UPDATER_H +#define VGPU_UPDATER_H + +#include +#include +#include + +#define VGPU_UPDATE_VERSION_CAP 32 +#define VGPU_UPDATE_INSTALLER_CAP 128 + +typedef struct { + char version[VGPU_UPDATE_VERSION_CAP]; + char installer_name[VGPU_UPDATE_INSTALLER_CAP]; + unsigned char sha256[32]; +} VgpuUpdateManifest; + +typedef enum { + VGPU_UPDATE_SKIPPED, + VGPU_UPDATE_CURRENT, + VGPU_UPDATE_STARTED, + VGPU_UPDATE_ERROR +} VgpuUpdateResult; + +bool updater_parse_version(const char *text, unsigned int parts[3]); +bool updater_compare_versions(const char *left, const char *right, int *result); +bool updater_parse_manifest(const char *text, VgpuUpdateManifest *manifest); +bool updater_should_check(int argc, wchar_t **argv); +bool updater_is_forced(int argc, wchar_t **argv); +VgpuUpdateResult updater_check_and_start(int argc, wchar_t **argv, + const char *current_version, + bool force); + +#endif diff --git a/src/version.h b/src/version.h index 69cf882..d096174 100644 --- a/src/version.h +++ b/src/version.h @@ -1,7 +1,7 @@ #ifndef VGPU_VERSION_H #define VGPU_VERSION_H -#define VGPU_VERSION "1.1.4" -#define VGPU_VERSION_NUM 1,1,4,0 +#define VGPU_VERSION "1.2.0" +#define VGPU_VERSION_NUM 1,2,0,0 #endif diff --git a/tests/test_cli.ps1 b/tests/test_cli.ps1 index 14356a7..af037fb 100644 --- a/tests/test_cli.ps1 +++ b/tests/test_cli.ps1 @@ -45,6 +45,11 @@ if ($version.Trim() -ne "VGPU-Mon $expectedVersion") { throw "Unexpected version output: $version" } +$versionWithoutUpdate = (Invoke-ExpectedExit 0 @('--no-update', '--version')) -join "`n" +if ($versionWithoutUpdate.Trim() -ne "VGPU-Mon $expectedVersion") { + throw "Unexpected --no-update version output: $versionWithoutUpdate" +} + $jsonText = (Invoke-ExpectedExit 0 @('--demo', '--interval', '250', '--json')) -join "`n" $snapshot = $jsonText | ConvertFrom-Json if ($snapshot.name -ne 'VGPU-Mon Demo GPU') { diff --git a/tests/test_core.c b/tests/test_core.c index 0d154d5..008b577 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -1,6 +1,7 @@ #define _CRT_SECURE_NO_WARNINGS #include "../src/vgpu.h" #include "../src/pdh_gpu.h" +#include "../src/updater.h" #include #include @@ -61,6 +62,53 @@ static void test_safe_process_helpers(void) { EXPECT(strstr(message, "Refused") != NULL); } +static void test_updater_manifest(void) { + unsigned int parts[3]; + EXPECT(updater_parse_version("1.2.0", parts)); + EXPECT(parts[0] == 1 && parts[1] == 2 && parts[2] == 0); + EXPECT(!updater_parse_version("1.2", parts)); + EXPECT(!updater_parse_version("01.2.0", parts)); + EXPECT(!updater_parse_version("1.2.0-beta", parts)); + + int comparison = 99; + EXPECT(updater_compare_versions("1.2.0", "1.2.1", &comparison) && comparison < 0); + EXPECT(updater_compare_versions("2.0.0", "1.9.9", &comparison) && comparison > 0); + EXPECT(updater_compare_versions("1.2.0", "1.2.0", &comparison) && comparison == 0); + + const char *valid = + "version=1.2.0\r\n" + "installer=VGPU-Mon-1.2.0-setup.exe\r\n" + "sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\r\n"; + VgpuUpdateManifest manifest; + EXPECT(updater_parse_manifest(valid, &manifest)); + EXPECT(strcmp(manifest.version, "1.2.0") == 0); + EXPECT(strcmp(manifest.installer_name, "VGPU-Mon-1.2.0-setup.exe") == 0); + EXPECT(manifest.sha256[0] == 0x01 && manifest.sha256[31] == 0xef); + + EXPECT(!updater_parse_manifest( + "version=1.2.0\ninstaller=..\\evil.exe\n" + "sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n", + &manifest)); + EXPECT(!updater_parse_manifest( + "version=1.2.0\ninstaller=VGPU-Mon-1.2.0-setup.exe\nsha256=bad\n", + &manifest)); + EXPECT(!updater_parse_manifest( + "version=1.2.0\nversion=1.2.0\ninstaller=VGPU-Mon-1.2.0-setup.exe\n" + "sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n", + &manifest)); +} + +static void test_updater_argument_policy(void) { + wchar_t *interactive[] = {L"vgpu.exe", L"--chart", L"vram"}; + wchar_t *json[] = {L"vgpu.exe", L"--json"}; + wchar_t *disabled[] = {L"vgpu.exe", L"--no-update"}; + wchar_t *forced[] = {L"vgpu.exe", L"--update"}; + EXPECT(updater_should_check(3, interactive)); + EXPECT(!updater_should_check(2, json)); + EXPECT(!updater_should_check(2, disabled)); + EXPECT(updater_is_forced(2, forced)); +} + int main(void) { #ifdef _DEBUG _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE); @@ -71,6 +119,8 @@ int main(void) { test_parse_gpu_instance(); test_text_helpers(); test_safe_process_helpers(); + test_updater_manifest(); + test_updater_argument_policy(); if (failures) { fprintf(stderr, "%d test(s) failed\n", failures); return 1; diff --git a/tests/test_live_update.ps1 b/tests/test_live_update.ps1 new file mode 100644 index 0000000..efbd6f8 --- /dev/null +++ b/tests/test_live_update.ps1 @@ -0,0 +1,149 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$Executable, + + [Parameter(Mandatory)] + [ValidatePattern('^[0-9]+\.[0-9]+\.[0-9]+$')] + [string]$ExpectedVersion +) + +$ErrorActionPreference = 'Stop' +$smokeExecutable = (Resolve-Path -LiteralPath $Executable).Path +$installDir = Join-Path $env:LOCALAPPDATA 'Programs\VGPU-Mon' +$installedExe = Join-Path $installDir 'vgpu.exe' +$uninstaller = Join-Path $installDir 'unins000.exe' + +function Get-UserPathState { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + try { + [pscustomobject]@{ + Value = [string]$key.GetValue( + 'Path', + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + Kind = $key.GetValueKind('Path').ToString() + } + } + finally { + $key.Close() + } +} + +function Invoke-HiddenProcess { + param([string]$FilePath, [string[]]$Arguments) + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments ` + -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0) { + throw "$FilePath exited with code $($process.ExitCode)." + } +} + +if (Test-Path -LiteralPath $installDir) { + throw "Refusing to overwrite an existing installation at $installDir." +} + +$before = Get-UserPathState +$beforeTemporaryFiles = @( + Get-ChildItem -LiteralPath ([IO.Path]::GetTempPath()) -Filter 'VGPU-Mon-*' -File ` + -ErrorAction SilentlyContinue | ForEach-Object FullName +) + +function Get-NewUpdaterTemporaryFiles { + @( + Get-ChildItem -LiteralPath ([IO.Path]::GetTempPath()) -Filter 'VGPU-Mon-*' -File ` + -ErrorAction SilentlyContinue | Where-Object { + $_.FullName -notin $beforeTemporaryFiles + } + ) +} + +function Wait-UpdaterTemporaryCleanup { + param([int]$Seconds) + $cleanupDeadline = [DateTime]::UtcNow.AddSeconds($Seconds) + do { + $files = @(Get-NewUpdaterTemporaryFiles) + if ($files.Count -eq 0) { return @() } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $cleanupDeadline) + return $files +} + +$installed = $false +try { + $handoffStarted = $false + for ($attempt = 1; $attempt -le 12; $attempt++) { + $process = Start-Process -FilePath $smokeExecutable -Wait -PassThru -NoNewWindow + if ($process.ExitCode -eq 0) { + $handoffStarted = $true + break + } + if ($attempt -lt 12) { + Start-Sleep -Seconds 5 + } + } + if (-not $handoffStarted) { + throw 'The live updater could not resolve the newly published release.' + } + + $deadline = [DateTime]::UtcNow.AddSeconds(90) + do { + Start-Sleep -Milliseconds 500 + if (Test-Path -LiteralPath $installedExe) { + $versionInfo = (Get-Item -LiteralPath $installedExe).VersionInfo + if ($versionInfo.FileVersion -eq $ExpectedVersion -and + (Test-Path -LiteralPath $uninstaller)) { + $installed = $true + break + } + } + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $installed) { + throw "The updater did not install VGPU-Mon $ExpectedVersion before the timeout." + } + + $remainingTemporaryFiles = @(Wait-UpdaterTemporaryCleanup -Seconds 30) + if ($remainingTemporaryFiles.Count -ne 0) { + throw "The updater handoff did not finish: $($remainingTemporaryFiles.FullName -join ', ')" + } + + $version = & $installedExe --no-update --version + if ($LASTEXITCODE -ne 0 -or $version -ne "VGPU-Mon $ExpectedVersion") { + throw "The updated executable failed its version smoke test: $version" + } + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $pathEntries = @($userPath -split ';' | Where-Object { + $_.Trim().Trim('"').TrimEnd('\') -ieq $installDir + }) + if ($pathEntries.Count -ne 1) { + throw "The updater produced $($pathEntries.Count) VGPU-Mon PATH entries." + } + + Invoke-HiddenProcess $uninstaller @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') + $installed = $false +} +finally { + if (Test-Path -LiteralPath $uninstaller) { + Invoke-HiddenProcess $uninstaller @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') + } +} + +$after = Get-UserPathState +if ($after.Value -cne $before.Value -or $after.Kind -cne $before.Kind) { + throw 'The live update lifecycle changed the original user PATH.' +} +if (Test-Path -LiteralPath $installDir) { + throw 'The live update lifecycle left the application directory behind.' +} +if (Test-Path -LiteralPath 'HKCU:\Software\VGPU-Mon') { + throw 'The live update lifecycle left installer registry state behind.' +} + +$newTemporaryFiles = @(Wait-UpdaterTemporaryCleanup -Seconds 10) +if ($newTemporaryFiles.Count -ne 0) { + throw "Updater temporary files were not cleaned: $($newTemporaryFiles.FullName -join ', ')" +} + +Write-Host "Live automatic update, relaunch, PATH, cleanup, and uninstall tests passed for $ExpectedVersion." diff --git a/tests/update_smoke.c b/tests/update_smoke.c new file mode 100644 index 0000000..f791c6f --- /dev/null +++ b/tests/update_smoke.c @@ -0,0 +1,13 @@ +#include "../src/updater.h" + +#include + +int wmain(int argc, wchar_t **argv) { + (void)argc; + wchar_t *update_arguments[] = {argv[0], L"--update"}; + VgpuUpdateResult result = updater_check_and_start( + 2, update_arguments, "0.0.0", true); + if (result == VGPU_UPDATE_STARTED) return 0; + fprintf(stderr, "Updater smoke handoff did not start (result %d).\n", (int)result); + return result == VGPU_UPDATE_CURRENT ? 2 : 3; +} diff --git a/tools/stage-release-metadata.ps1 b/tools/stage-release-metadata.ps1 new file mode 100644 index 0000000..e2789f3 --- /dev/null +++ b/tools/stage-release-metadata.ps1 @@ -0,0 +1,44 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$dist = Join-Path $root 'dist' +$versionHeader = Get-Content -LiteralPath (Join-Path $root 'src\version.h') -Raw +$versionMatch = [regex]::Match( + $versionHeader, + '#define\s+VGPU_VERSION\s+"([0-9]+\.[0-9]+\.[0-9]+)"' +) +if (-not $versionMatch.Success) { + throw 'Could not read VGPU_VERSION from src\version.h.' +} +$version = $versionMatch.Groups[1].Value +$setup = Join-Path $dist "VGPU-Mon-$version-setup.exe" +$archive = Join-Path $dist "VGPU-Mon-$version-windows-x64.zip" +foreach ($required in @($setup, $archive, (Join-Path $root 'install.ps1'))) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "Required release asset was not found: $required" + } +} + +$bootstrap = Join-Path $dist 'install.ps1' +$versionManifest = Join-Path $dist 'version.txt' +$checksumManifest = Join-Path $dist 'SHA256SUMS.txt' +Copy-Item -LiteralPath (Join-Path $root 'install.ps1') -Destination $bootstrap -Force + +$setupHash = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash.ToLowerInvariant() +@( + "version=$version" + "installer=$(Split-Path -Leaf $setup)" + "sha256=$setupHash" +) | Set-Content -LiteralPath $versionManifest -Encoding ascii + +$assets = @($archive, $setup, $bootstrap, $versionManifest) | + Sort-Object { Split-Path -Leaf $_ } +$checksumLines = foreach ($asset in $assets) { + $hash = (Get-FileHash -LiteralPath $asset -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $(Split-Path -Leaf $asset)" +} +$checksumLines | Set-Content -LiteralPath $checksumManifest -Encoding ascii + +Write-Host "Staged release metadata for VGPU-Mon $version."