diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aff6857..122ddbb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: build: name: Windows x64 (${{ matrix.configuration }}) runs-on: windows-2022 - timeout-minutes: 15 + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -37,11 +37,34 @@ jobs: } - name: Configure - run: cmake -S . -B build -G "Visual Studio 17 2022" -A x64 + run: | + $coverage = if ('${{ matrix.configuration }}' -eq 'Debug') { 'ON' } else { 'OFF' } + cmake -S . -B build -G "Visual Studio 17 2022" -A x64 -DSTEAMFIX_ENABLE_CODE_ANALYSIS=ON "-DSTEAMFIX_ENABLE_COVERAGE=$coverage" - name: Build run: cmake --build build --config ${{ matrix.configuration }} --parallel + - name: Test + if: matrix.configuration != 'Debug' + run: ctest --test-dir build -C ${{ matrix.configuration }} --output-on-failure --no-tests=error + + - name: Install coverage tool + if: matrix.configuration == 'Debug' + run: choco install opencppcoverage --version 0.9.9.0 --yes --no-progress + + - name: Test with production line coverage + id: coverage + if: matrix.configuration == 'Debug' + run: ./cmake/RunCoverage.ps1 -BuildDirectory build + + - name: Upload coverage report + if: always() && matrix.configuration == 'Debug' && steps.coverage.outcome != 'skipped' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: steamfix-windows-x64-coverage + path: build/coverage/ + if-no-files-found: error + - name: Upload executable uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.gitignore b/.gitignore index 658847e..c251a98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /build/ +/build-analysis/ +/build-coverage/ /.vs/ /out/ *.log *.log.old -*.dat \ No newline at end of file +*.dat diff --git a/AGENTS.md b/AGENTS.md index 3a28bd2..8140e6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ D2SteamFix is a Windows x64 C++20 launch wrapper for the Steam version of Destin ## Validation -Follow [docs/BUILDING.md](docs/BUILDING.md) and build both Debug and Release on Windows x64 with MSVC. CI runs those builds for pushes and pull requests. This repository has no automated runtime test suite; do not claim runtime validation from a successful compile. +Follow [docs/BUILDING.md](docs/BUILDING.md) and build both Debug and Release on Windows x64 with MSVC. CI runs those builds and the automated tests for pushes and pull requests. Tests use controlled fixtures; do not claim live Steam, desktop, UAC, or repair validation from a successful compile or unit-test run. For changes to launch, permissions, elevation, or repair, manually check the affected paths on a Windows test installation: diff --git a/CMakeLists.txt b/CMakeLists.txt index 3974692..2ab79e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.24) -project(D2SteamFix VERSION 1.1.0 LANGUAGES CXX) +project(D2SteamFix VERSION 1.1.1 LANGUAGES CXX) set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") @@ -11,11 +11,28 @@ if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8) message(FATAL_ERROR "D2SteamFix supports only 64-bit Windows.") endif() +option(STEAMFIX_ENABLE_CODE_ANALYSIS "Run Microsoft C++ Core Check during the build" OFF) +set(STEAMFIX_CODE_ANALYSIS_RULESET "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CoreGuidelines.ruleset" + CACHE STRING "Microsoft native code analysis ruleset") +if(STEAMFIX_ENABLE_CODE_ANALYSIS AND NOT CMAKE_GENERATOR MATCHES "^Visual Studio") + message(FATAL_ERROR "STEAMFIX_ENABLE_CODE_ANALYSIS requires a Visual Studio generator.") +endif() + +function(steamfix_enable_code_analysis target) + if(STEAMFIX_ENABLE_CODE_ANALYSIS) + set_target_properties(${target} PROPERTIES + VS_GLOBAL_EnableCppCoreCheck true + VS_GLOBAL_RunCodeAnalysis true + VS_GLOBAL_CodeAnalysisRuleSet "${STEAMFIX_CODE_ANALYSIS_RULESET}" + VS_GLOBAL_CodeAnalysisTreatWarningsAsErrors true + ) + endif() +endfunction() + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/src/steamfix.rc.in" "${CMAKE_CURRENT_BINARY_DIR}/steamfix.rc" @ONLY) -add_executable(steamfix WIN32 - src/main.cpp +set(STEAMFIX_SOURCES src/acl.cpp src/app.cpp src/elevation.cpp @@ -26,9 +43,14 @@ add_executable(steamfix WIN32 src/process.cpp src/repair.cpp src/steam.cpp + src/ui.cpp +) +set(STEAMFIX_LIBRARIES advapi32 comctl32 comdlg32 crypt32 ole32 shell32 user32) +add_executable(steamfix WIN32 + src/main.cpp + ${STEAMFIX_SOURCES} "${CMAKE_CURRENT_BINARY_DIR}/steamfix.rc" src/steamfix.manifest - src/ui.cpp ) target_include_directories(steamfix PRIVATE include) target_compile_features(steamfix PRIVATE cxx_std_20) @@ -49,8 +71,75 @@ target_link_options(steamfix PRIVATE $<$:/Brepro> $<$:/PDBALTPATH:steamfix.pdb> ) -target_link_libraries(steamfix PRIVATE advapi32 comctl32 comdlg32 crypt32 ole32 shell32 user32) +target_link_libraries(steamfix PRIVATE ${STEAMFIX_LIBRARIES}) set_target_properties(steamfix PROPERTIES INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) +steamfix_enable_code_analysis(steamfix) + +include(CTest) +option(STEAMFIX_ENABLE_COVERAGE "Build the complete Debug coverage source inventory" OFF) +if(STEAMFIX_ENABLE_COVERAGE AND NOT BUILD_TESTING) + message(FATAL_ERROR "STEAMFIX_ENABLE_COVERAGE requires BUILD_TESTING.") +endif() + +function(steamfix_add_test target) + cmake_parse_arguments(TEST "" "" "ARGS" ${ARGN}) + # Repair code imports TaskDialogIndirect, which requires Common Controls 6 + # even when a test never opens a dialog. Match the application's manifest. + add_executable(${target} ${TEST_UNPARSED_ARGUMENTS} src/steamfix.manifest) + target_include_directories(${target} PRIVATE include) + target_compile_features(${target} PRIVATE cxx_std_20) + target_compile_definitions(${target} PRIVATE + UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX STEAMFIX_VERSION="${PROJECT_VERSION}") + target_compile_options(${target} PRIVATE /W4 /WX /permissive- /EHsc /utf-8 /sdl) + target_link_libraries(${target} PRIVATE ${STEAMFIX_LIBRARIES}) + steamfix_enable_code_analysis(${target}) + add_test(NAME ${target} COMMAND ${target} ${TEST_ARGS}) + set_tests_properties(${target} PROPERTIES TIMEOUT 120) +endfunction() + +if(BUILD_TESTING) + steamfix_add_test(command_line_tests tests/command_line_tests.cpp src/command_line.cpp) + steamfix_add_test(win32_tests tests/win32_tests.cpp) + + set(STEAMFIX_APP_TEST_SOURCES ${STEAMFIX_SOURCES}) + list(REMOVE_ITEM STEAMFIX_APP_TEST_SOURCES src/acl.cpp src/app.cpp src/ui.cpp) + steamfix_add_test(app_tests tests/app_tests.cpp ${STEAMFIX_APP_TEST_SOURCES} ARGS --entry-check) + steamfix_add_test(acl_tests tests/acl_tests.cpp + src/elevation.cpp src/logging.cpp src/paths.cpp src/command_line.cpp) + steamfix_add_test(elevation_tests tests/elevation_tests.cpp src/command_line.cpp src/paths.cpp) + steamfix_add_test(runtime_tests tests/runtime_tests.cpp + src/command_line.cpp src/elevation.cpp src/logging.cpp src/ui.cpp) + steamfix_add_test(process_fault_tests tests/process_fault_tests.cpp + src/elevation.cpp src/logging.cpp src/paths.cpp src/command_line.cpp) + steamfix_add_test(repair_workflow_tests tests/repair_workflow_tests.cpp + src/command_line.cpp src/elevation.cpp src/logging.cpp src/paths.cpp src/process.cpp src/steam.cpp) + steamfix_add_test(ui_entry_tests tests/ui_entry_tests.cpp src/ui.cpp src/command_line.cpp + ARGS $) + add_dependencies(ui_entry_tests steamfix) + set_tests_properties(ui_entry_tests PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60) + add_library(overlay_fixture SHARED tests/overlay_fixture.cpp) + set_target_properties(overlay_fixture PROPERTIES OUTPUT_NAME overlay_fixture PREFIX "") + target_compile_features(overlay_fixture PRIVATE cxx_std_20) + target_compile_options(overlay_fixture PRIVATE /W4 /WX /permissive- /EHsc /utf-8 /sdl) + add_dependencies(runtime_tests overlay_fixture) + add_dependencies(app_tests overlay_fixture) + + if(STEAMFIX_ENABLE_COVERAGE) + # Load every production translation unit in one Debug module. This keeps + # wholly uncalled code in the coverage denominator; it is not a test. + add_executable(steamfix_coverage_inventory + cmake/coverage_inventory.cpp src/main.cpp ${STEAMFIX_SOURCES} src/steamfix.manifest) + target_include_directories(steamfix_coverage_inventory PRIVATE include) + target_compile_features(steamfix_coverage_inventory PRIVATE cxx_std_20) + target_compile_definitions(steamfix_coverage_inventory PRIVATE + UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX STEAMFIX_VERSION="${PROJECT_VERSION}") + target_compile_options(steamfix_coverage_inventory PRIVATE + /W4 /WX /permissive- /EHsc /utf-8 /sdl /Zi /Od /Ob0) + target_link_options(steamfix_coverage_inventory PRIVATE + /DEBUG:FULL /OPT:NOREF /OPT:NOICF /INCREMENTAL:NO) + target_link_libraries(steamfix_coverage_inventory PRIVATE ${STEAMFIX_LIBRARIES}) + endif() +endif() install(TARGETS steamfix RUNTIME DESTINATION .) install(FILES LICENSE.md README.md DESTINATION .) diff --git a/cmake/CoreGuidelines.ruleset b/cmake/CoreGuidelines.ruleset new file mode 100644 index 0000000..f8eb1bf --- /dev/null +++ b/cmake/CoreGuidelines.ruleset @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/cmake/RunCoverage.ps1 b/cmake/RunCoverage.ps1 new file mode 100644 index 0000000..bc88c6b --- /dev/null +++ b/cmake/RunCoverage.ps1 @@ -0,0 +1,74 @@ +param( + [string]$BuildDirectory = "build-coverage", + [string]$OpenCppCoverage = "" +) + +$ErrorActionPreference = "Stop" +$sourceRoot = (Get-Item (Join-Path $PSScriptRoot "..")).FullName +$buildRoot = (Resolve-Path $BuildDirectory).Path +$inventory = Join-Path $buildRoot "Debug/steamfix_coverage_inventory.exe" +if (-not (Test-Path $inventory)) { + throw "Build Debug with STEAMFIX_ENABLE_COVERAGE=ON before collecting coverage." +} + +if (-not $OpenCppCoverage) { + $coverageCommand = Get-Command OpenCppCoverage.exe -ErrorAction SilentlyContinue + if ($coverageCommand) { + $OpenCppCoverage = $coverageCommand.Source + } else { + $OpenCppCoverage = Join-Path $env:ProgramFiles "OpenCppCoverage/OpenCppCoverage.exe" + } +} +if (-not (Test-Path $OpenCppCoverage)) { + throw "OpenCppCoverage was not found. Install OpenCppCoverage or pass -OpenCppCoverage." +} + +$outputRoot = Join-Path $buildRoot "coverage" +New-Item -ItemType Directory -Force $outputRoot | Out-Null +$archivedTestLog = Join-Path $outputRoot "LastTest.log" +if (Test-Path $archivedTestLog) { + Remove-Item -LiteralPath $archivedTestLog -Force +} +$coverageArguments = @( + "--cover_children", + "--continue_after_cpp_exception", + "--modules", $buildRoot, + "--sources", (Join-Path $sourceRoot "src"), + "--sources", (Join-Path $sourceRoot "include"), + "--export_type", "cobertura:$(Join-Path $outputRoot 'coverage.xml')", + "--export_type", "html:$(Join-Path $outputRoot 'html')", + "--", (Get-Command cmake).Source, + "-DSTEAMFIX_INVENTORY=$inventory", + "-DSTEAMFIX_CTEST=$((Get-Command ctest).Source)", + "-DSTEAMFIX_BINARY_DIR=$buildRoot", + "-P", (Join-Path $PSScriptRoot "RunCoverageTests.cmake") +) +& $OpenCppCoverage @coverageArguments +$coverageStatus = $LASTEXITCODE +if (Test-Path "LastCoverageResults.log") { + Copy-Item "LastCoverageResults.log" (Join-Path $outputRoot "LastCoverageResults.log") -Force +} +$testLog = Join-Path $buildRoot "Testing/Temporary/LastTest.log" +if (Test-Path $testLog) { + Copy-Item $testLog $archivedTestLog -Force +} + +if (Test-Path (Join-Path $outputRoot "coverage.xml")) { + $summaryArguments = @( + (Join-Path $PSScriptRoot "SummarizeCoverage.py"), + (Join-Path $outputRoot "coverage.xml"), "--source-root", $sourceRoot, + "--output", (Join-Path $outputRoot "summary.md") + ) + if ($coverageStatus -ne 0) { + $summaryArguments += "--test-run-failed" + } + python @summaryArguments + if ($LASTEXITCODE -ne 0) { + throw "Coverage report is incomplete; inspect coverage/summary.md." + } +} else { + throw "OpenCppCoverage did not create the Cobertura report." +} +if ($coverageStatus -ne 0) { + throw "Coverage collection or a regression test failed with exit code $coverageStatus." +} diff --git a/cmake/RunCoverageTests.cmake b/cmake/RunCoverageTests.cmake new file mode 100644 index 0000000..9fbfcc4 --- /dev/null +++ b/cmake/RunCoverageTests.cmake @@ -0,0 +1,12 @@ +file(REMOVE "${STEAMFIX_BINARY_DIR}/Testing/Temporary/LastTest.log") + +execute_process(COMMAND "${STEAMFIX_INVENTORY}" RESULT_VARIABLE inventory_status) +if(NOT "${inventory_status}" STREQUAL "0") + message(FATAL_ERROR "Coverage inventory failed: ${inventory_status}") +endif() + +execute_process(COMMAND "${STEAMFIX_CTEST}" --test-dir "${STEAMFIX_BINARY_DIR}" + -C Debug --output-on-failure --no-tests=error RESULT_VARIABLE test_status) +if(NOT "${test_status}" STREQUAL "0") + message(FATAL_ERROR "Coverage tests failed: ${test_status}") +endif() diff --git a/cmake/SummarizeCoverage.py b/cmake/SummarizeCoverage.py new file mode 100644 index 0000000..a3975df --- /dev/null +++ b/cmake/SummarizeCoverage.py @@ -0,0 +1,110 @@ +"""Report the union of instrumented production lines without hiding uncovered TUs.""" + +import argparse +import os +from pathlib import Path +import xml.etree.ElementTree as ET + + +def line_ranges(lines): + ranges = [] + for line in sorted(lines): + if ranges and line == ranges[-1][1] + 1: + ranges[-1][1] = line + else: + ranges.append([line, line]) + return ", ".join(str(start) if start == end else f"{start}-{end}" for start, end in ranges) + + +def summarize(report, source_root, test_run_failed=False): + files = {} + document = ET.parse(report) + source_roots = [source_root] + for source in document.findall("./sources/source"): + root = (source.text or "").replace("\\", "/") + if len(root) == 2 and root[1] == ":": + # OpenCppCoverage stores root_name() ("D:") separately from the + # drive-relative filename. Resolve from D:/, not D:'s current dir. + root += "/" + if root: + source_roots.append(Path(root)) + for item in document.iter("class"): + source = Path(item.attrib["filename"].replace("\\", "/")) + candidates = [source] if source.is_absolute() else [root / source for root in source_roots] + relative = None + for candidate in candidates: + try: + path = candidate.resolve().relative_to(source_root).as_posix() + except ValueError: + continue + if path.startswith(("src/", "include/")): + relative = path + break + if relative is None: + continue + lines = files.setdefault(relative, {}) + for line in item.findall("./lines/line"): + number = int(line.attrib["number"]) + lines[number] = lines.get(number, False) or int(line.attrib["hits"]) > 0 + + missing = sorted( + source.relative_to(source_root).as_posix() + for source in (source_root / "src").glob("*.cpp") + if source.relative_to(source_root).as_posix() not in files + ) + total = sum(len(lines) for lines in files.values()) + covered = sum(sum(lines.values()) for lines in files.values()) + percentage = 100.0 * covered / total if total else 0.0 + complete = not missing and total > 0 + label = "Measured coverage" if complete else "Partial coverage (incomplete denominator)" + if test_run_failed: + label = "Coverage from a failed test run; not a passing-suite baseline" + output = [ + "# Production line coverage", + "", + f"{label}: **{percentage:.2f}%** ({covered}/{total} instrumented lines).", + "", + "The inventory is built from every production translation unit, including code no test calls. " + "Only executable lines described by Debug symbols contribute to this measurement; " + "comments, declarations, and uninstantiated templates do not. This is line coverage, " + "not branch coverage or validation of live Steam/UAC behavior.", + "", + "| File | Covered / executable | Coverage |", + "| --- | ---: | ---: |", + ] + for name, lines in sorted(files.items()): + hits = sum(lines.values()) + percent = 100.0 * hits / len(lines) if lines else 0.0 + output.append(f"| `{name}` | {hits} / {len(lines)} | {percent:.2f}% |") + output.extend(["", "## Uncovered executable lines", ""]) + for name, lines in sorted(files.items()): + uncovered = [number for number, hit in lines.items() if not hit] + if uncovered: + output.append(f"- `{name}`: {line_ranges(uncovered)}") + if missing: + output.extend(["", "## Missing production translation units", ""]) + output.extend(f"- `{name}`" for name in missing) + output.extend(["", "The report is incomplete; no coverage threshold is evaluated."]) + return "\n".join(output) + "\n", complete + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("report", type=Path) + parser.add_argument("--source-root", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--test-run-failed", action="store_true") + arguments = parser.parse_args() + summary, complete = summarize( + arguments.report, arguments.source_root.resolve(), arguments.test_run_failed + ) + arguments.output.write_text(summary, encoding="utf-8") + print(summary) + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as output: + output.write(summary) + return 0 if complete else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cmake/coverage_inventory.cpp b/cmake/coverage_inventory.cpp new file mode 100644 index 0000000..a06198b --- /dev/null +++ b/cmake/coverage_inventory.cpp @@ -0,0 +1,6 @@ +// This program only loads debug symbols for all application translation units. +// RunCoverage.ps1 collects its unexecuted lines together with the real tests so +// code that no test reaches still contributes to the coverage denominator. +int main() { + return 0; +} diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 037c879..f96b7ff 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -27,7 +27,24 @@ make.cmd debug The Debug executable is written to `build\Debug\steamfix.exe`. -To remove the build directory: +To build and run the regression tests in Debug: + +```batch +make.cmd test +``` + +To build with Microsoft C++ Core Check and run those tests: + +```batch +make.cmd analyze +``` + +The `analyze` task uses a separate `build-analysis` directory. The checked-in +[ruleset](../cmake/CoreGuidelines.ruleset) treats the selected resource ownership, +special-member, initialization, and lifetime diagnostics as errors. This is a +focused enforcement baseline, not an automated check of every Core Guideline. + +To remove the `build`, `build-analysis`, and `build-coverage` directories: ```batch make.cmd clean @@ -40,15 +57,92 @@ This preserves runtime logs and recovery files. Do not delete `steamfix.dat` fro To use the same generator and architecture as CI: ```batch -cmake -S . -B build -G "Visual Studio 17 2022" -A x64 +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 -DSTEAMFIX_ENABLE_CODE_ANALYSIS=ON cmake --build build --config Debug --parallel +ctest --test-dir build -C Debug --output-on-failure --no-tests=error cmake --build build --config Release --parallel +ctest --test-dir build -C Release --output-on-failure --no-tests=error ``` +Analysis requires a Visual Studio generator. To run the complete set of currently +implemented Microsoft Core Check rules for additional review, configure with +`-DSTEAMFIX_CODE_ANALYSIS_RULESET=CppCoreCheckRules.ruleset`. The complete ruleset +can produce additional diagnostics for Win32 API interoperability and is not the +CI baseline. Set `-DBUILD_TESTING=OFF` when only the application is needed. + +## Coverage + +Install the 64-bit [OpenCppCoverage](https://github.com/OpenCppCoverage/OpenCppCoverage) +tool and Python 3, then run: + +```batch +make.cmd coverage +``` + +This builds Debug in `build-coverage` and runs the tests under OpenCppCoverage. +The results are written to `build-coverage\coverage`: `html\index.html` shows +annotated sources, `coverage.xml` contains Cobertura data, and `summary.md` gives +the measured total, per-file coverage, and every uncovered executable line range. +`LastTest.log` retains output from passing suites too, including any conditional +privilege or filesystem skips; `LastCoverageResults.log` records the collector. +The Debug CI job publishes the same files as the +`steamfix-windows-x64-coverage` artifact and adds the summary to the workflow run. + +The coverage inventory executable loads Debug symbols for every production +translation unit, including `main.cpp` and the real dialog implementation. It +performs no test itself. OpenCppCoverage combines its unexecuted lines with +coverage from the tests and their child processes, so completely uncalled code +still contributes to the denominator. The summary rejects a report that omits a +production `.cpp` file. No production lines are excluded to improve the score, +and no arbitrary coverage threshold is substituted for a measured result. + +Line coverage counts executable lines described by Debug symbols. It does not +measure branch coverage, comments, declarations, or uninstantiated templates, +and a high percentage does not establish correctness of real Steam or UAC flows. + ## Validation Build both Debug and Release when changing code. The compiler treats warnings as errors (`/W4 /WX`), and both configurations must compile successfully. -[GitHub Actions](../.github/workflows/build.yml) builds both configurations on Windows x64 for pushes and pull requests. The executables are available as workflow artifacts named `steamfix-windows-x64-Debug` and `steamfix-windows-x64-Release`. - -There is no automated runtime test suite in the repository. See [AGENTS.md](../AGENTS.md) for behavior to check manually when changing launch or recovery code. Use the [README](../README.md) for installation and normal use. +[GitHub Actions](../.github/workflows/build.yml) builds both configurations with +the focused C++ Core Check ruleset and runs CTest on Windows x64 for pushes and +pull requests. The executables are available as workflow artifacts named +`steamfix-windows-x64-Debug` and `steamfix-windows-x64-Release`. + +The tests use real Windows APIs with files, processes, named pipes, security +descriptors, and registry fixtures owned by the test. They cover: + +- Command-line quoting, Win32 handle ownership, text conversion, and exit-status bits. +- Invocation validation, environment handling, logging, and application launch, + monitoring, permission restoration, and launch-failure cleanup with fixture executables. +- Malformed ACL and recovery-journal inputs, DPAPI round trips, and restoration + of permissions on fixture files. +- Elevation helper validation, wire formats, overlapped pipe I/O, cancellation, + and temporary privilege restoration without requesting UAC. +- Broker startup, authentication, reuse, recovery after transport loss, and + administrator handoff through a scoped shell substitute that starts only the + same test executable. These cases require an already elevated test token; + actual UAC consent and alternate-account behavior remain manual checks. +- Path and Steam-directory validation, process discovery and termination of + fixture children, overlay-module identification, and repair helper behavior. +- Recovery from finite process-observation failures while monitoring real + fixture children. This separate test target injects failures at specific API + call sites without changing the production application. +- Repair orchestration with a fixture Steam executable, including consent, + selection, launch, verification, and recovery-cleanup failures. These tests + constrain discovery and process snapshots to fixture entries and supply + deterministic consent/selection responses; file, token, and process operations + act on real test-owned resources. +- Real information/error dialogs and the built wrapper's invalid-invocation + entry point, with dialogs dismissed only in child processes owned by the test. + +Application tests replace dialogs with headless sinks; the real dialog code is +also exercised by the separate UI test when a Windows desktop is available. +An independent native window probe verifies desktop access before any test child +is launched. Only recognized desktop-access failures in that probe report a +CTest skip (exit 77); a missing or hung dialog always fails, including the first +information dialog. Owned children that exit without a window or remain idle +verify both failure paths. The suite does not launch an installed Steam client, change +an installed game's permissions, request UAC, or perform a live Steam repair. +See [AGENTS.md](../AGENTS.md) for the affected paths that still need manual +Windows checks. Use the [README](../README.md) for installation and normal use. diff --git a/include/steamfix/acl.hpp b/include/steamfix/acl.hpp index 6c310f7..b45263f 100644 --- a/include/steamfix/acl.hpp +++ b/include/steamfix/acl.hpp @@ -1,9 +1,11 @@ -#pragma once +#ifndef STEAMFIX_ACL_HPP +#define STEAMFIX_ACL_HPP #include "steamfix/win32.hpp" #include #include +#include #include #include @@ -21,12 +23,13 @@ struct Backup { class Protection { public: - ~Protection(); + ~Protection() noexcept; Protection(const Protection&) = delete; Protection& operator=(const Protection&) = delete; Protection(Protection&&) = delete; + Protection& operator=(Protection&&) = delete; - static Protection apply(const std::vector& targets, + static Protection apply(std::span targets, std::filesystem::path state_path, const Logger& log); void restore(const Logger& log); @@ -37,7 +40,7 @@ class Protection { }; Protection(std::vector backups, std::vector handles, - std::filesystem::path state_path) + std::filesystem::path state_path) noexcept : backups_(std::move(backups)), handles_(std::move(handles)), state_path_(std::move(state_path)), state_(State::active) {} std::vector backups_; @@ -50,3 +53,5 @@ std::filesystem::path recovery_path(const std::filesystem::path& game_directory) bool recover_pending(const std::filesystem::path& path, const Logger& log); } // namespace steamfix::acl + +#endif // STEAMFIX_ACL_HPP diff --git a/include/steamfix/app.hpp b/include/steamfix/app.hpp index 213dddd..da1303b 100644 --- a/include/steamfix/app.hpp +++ b/include/steamfix/app.hpp @@ -1,7 +1,10 @@ -#pragma once +#ifndef STEAMFIX_APP_HPP +#define STEAMFIX_APP_HPP namespace steamfix::app { -int entry(); +int entry() noexcept; } // namespace steamfix::app + +#endif // STEAMFIX_APP_HPP diff --git a/include/steamfix/command_line.hpp b/include/steamfix/command_line.hpp index b108169..8420fe7 100644 --- a/include/steamfix/command_line.hpp +++ b/include/steamfix/command_line.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_COMMAND_LINE_HPP +#define STEAMFIX_COMMAND_LINE_HPP #include #include @@ -8,3 +9,5 @@ namespace steamfix::command_line { void append_quoted(std::wstring& line, std::wstring_view argument); } // namespace steamfix::command_line + +#endif // STEAMFIX_COMMAND_LINE_HPP diff --git a/include/steamfix/elevation.hpp b/include/steamfix/elevation.hpp index 2ca1766..16ca370 100644 --- a/include/steamfix/elevation.hpp +++ b/include/steamfix/elevation.hpp @@ -1,9 +1,13 @@ -#pragma once +#ifndef STEAMFIX_ELEVATION_HPP +#define STEAMFIX_ELEVATION_HPP #include "steamfix/win32.hpp" #include +#include #include +#include +#include #include #include #include @@ -23,20 +27,31 @@ class RelaunchRequired final : public std::runtime_error { RelaunchRequired(DWORD error, std::filesystem::path directory, std::filesystem::path launcher, std::filesystem::path steam_directory) : std::runtime_error("launcher requires administrator permission: " + windows_error(error)), - directory_(std::move(directory)), launcher_(std::move(launcher)), - steam_directory_(std::move(steam_directory)) {} - const std::filesystem::path& directory() const noexcept { return directory_; } - const std::filesystem::path& launcher() const noexcept { return launcher_; } - const std::filesystem::path& steam_directory() const noexcept { return steam_directory_; } + payload_(std::make_shared(std::move(directory), std::move(launcher), + std::move(steam_directory))) {} + ~RelaunchRequired() noexcept override = default; + RelaunchRequired(const RelaunchRequired&) noexcept = default; + RelaunchRequired& operator=(const RelaunchRequired&) noexcept = default; + RelaunchRequired(RelaunchRequired&& other) noexcept + : RelaunchRequired(static_cast(other)) {} + RelaunchRequired& operator=(RelaunchRequired&& other) noexcept { + return *this = static_cast(other); + } + const std::filesystem::path& directory() const noexcept { return payload_->directory; } + const std::filesystem::path& launcher() const noexcept { return payload_->launcher; } + const std::filesystem::path& steam_directory() const noexcept { return payload_->steam_directory; } private: - std::filesystem::path directory_; - std::filesystem::path launcher_; - std::filesystem::path steam_directory_; + struct Payload { + std::filesystem::path directory; + std::filesystem::path launcher; + std::filesystem::path steam_directory; + }; + std::shared_ptr payload_; }; -// Validate and strip the private elevated-coordinator prefix before normal entry. -void accept_elevated_launch(std::vector& arguments); -std::optional inherited_steam_directory(); +// Validate and strip the private elevated-coordinator prefix before normal entry, +// returning the authenticated Steam discovery override when one was supplied. +std::optional accept_elevated_launch(std::vector& arguments); std::optional relaunch_as_administrator(const std::vector& arguments, const std::filesystem::path& directory, const std::filesystem::path& steam_directory); @@ -69,3 +84,5 @@ bool launch_as_desktop_user(const std::filesystem::path& executable, PROCESS_INFORMATION& information); } // namespace steamfix::elevation + +#endif // STEAMFIX_ELEVATION_HPP diff --git a/include/steamfix/error.hpp b/include/steamfix/error.hpp index eb0e6fd..637e674 100644 --- a/include/steamfix/error.hpp +++ b/include/steamfix/error.hpp @@ -1,5 +1,7 @@ -#pragma once +#ifndef STEAMFIX_ERROR_HPP +#define STEAMFIX_ERROR_HPP +#include #include #include #include @@ -21,12 +23,19 @@ enum class ExitCode : int { class AppError final : public std::runtime_error { public: AppError(ExitCode code, std::string stage, std::string detail) - : std::runtime_error(stage + ": " + detail), code_(code), stage_(std::move(stage)), - detail_(std::move(detail)) {} + : std::runtime_error(stage + ": " + detail), code_(code), + context_(std::make_shared(Context{std::move(stage), std::move(detail)})) {} + ~AppError() noexcept override = default; + AppError(const AppError&) noexcept = default; + AppError& operator=(const AppError&) noexcept = default; + AppError(AppError&& other) noexcept : AppError(static_cast(other)) {} + AppError& operator=(AppError&& other) noexcept { + return *this = static_cast(other); + } [[nodiscard]] ExitCode code() const noexcept { return code_; } - [[nodiscard]] const std::string& stage() const noexcept { return stage_; } - [[nodiscard]] const std::string& detail() const noexcept { return detail_; } + [[nodiscard]] const std::string& stage() const noexcept { return context_->stage; } + [[nodiscard]] const std::string& detail() const noexcept { return context_->detail; } [[nodiscard]] bool launcher_started() const noexcept { return launcher_started_; } AppError& after_launch() noexcept { launcher_started_ = true; @@ -34,10 +43,16 @@ class AppError final : public std::runtime_error { } private: + // Exception copies must not allocate, including copies made by exception_ptr. + struct Context { + std::string stage; + std::string detail; + }; ExitCode code_; - std::string stage_; - std::string detail_; + std::shared_ptr context_; bool launcher_started_ = false; }; } // namespace steamfix + +#endif // STEAMFIX_ERROR_HPP diff --git a/include/steamfix/logging.hpp b/include/steamfix/logging.hpp index 3c48f43..d0d8d0b 100644 --- a/include/steamfix/logging.hpp +++ b/include/steamfix/logging.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_LOGGING_HPP +#define STEAMFIX_LOGGING_HPP #include "steamfix/win32.hpp" @@ -22,3 +23,5 @@ class Logger { }; } // namespace steamfix + +#endif // STEAMFIX_LOGGING_HPP diff --git a/include/steamfix/overlay.hpp b/include/steamfix/overlay.hpp index 9f6ce73..d69477b 100644 --- a/include/steamfix/overlay.hpp +++ b/include/steamfix/overlay.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_OVERLAY_HPP +#define STEAMFIX_OVERLAY_HPP #include #include @@ -6,3 +7,5 @@ namespace steamfix::overlay { std::optional loaded_renderer(); } + +#endif // STEAMFIX_OVERLAY_HPP diff --git a/include/steamfix/paths.hpp b/include/steamfix/paths.hpp index 0fb34d2..7222956 100644 --- a/include/steamfix/paths.hpp +++ b/include/steamfix/paths.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_PATHS_HPP +#define STEAMFIX_PATHS_HPP #include #include @@ -9,3 +10,5 @@ bool equal_windows(const std::filesystem::path& left, const std::filesystem::pat bool name_is(const std::filesystem::path& path, std::wstring_view expected); } // namespace steamfix::paths + +#endif // STEAMFIX_PATHS_HPP diff --git a/include/steamfix/process.hpp b/include/steamfix/process.hpp index d6f1551..63885f5 100644 --- a/include/steamfix/process.hpp +++ b/include/steamfix/process.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_PROCESS_HPP +#define STEAMFIX_PROCESS_HPP #include "steamfix/win32.hpp" @@ -22,3 +23,5 @@ std::optional monitor(HANDLE child, DWORD child_pid, const std::filesystem: std::vector parent_chain(); } // namespace steamfix::process + +#endif // STEAMFIX_PROCESS_HPP diff --git a/include/steamfix/repair.hpp b/include/steamfix/repair.hpp index e22e4be..c54ba4a 100644 --- a/include/steamfix/repair.hpp +++ b/include/steamfix/repair.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_REPAIR_HPP +#define STEAMFIX_REPAIR_HPP #include @@ -16,3 +17,5 @@ bool run(const std::filesystem::path& game_directory, const std::filesystem::path& recovery_path, const Logger& log); } // namespace steamfix::repair + +#endif // STEAMFIX_REPAIR_HPP diff --git a/include/steamfix/steam.hpp b/include/steamfix/steam.hpp index 126dcf6..26bdcc6 100644 --- a/include/steamfix/steam.hpp +++ b/include/steamfix/steam.hpp @@ -1,7 +1,10 @@ -#pragma once +#ifndef STEAMFIX_STEAM_HPP +#define STEAMFIX_STEAM_HPP #include namespace steamfix::steam { std::filesystem::path discover(); } + +#endif // STEAMFIX_STEAM_HPP diff --git a/include/steamfix/ui.hpp b/include/steamfix/ui.hpp index 21d10fa..9fb9285 100644 --- a/include/steamfix/ui.hpp +++ b/include/steamfix/ui.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_UI_HPP +#define STEAMFIX_UI_HPP #include @@ -6,3 +7,5 @@ namespace steamfix::ui { void info(std::wstring_view message) noexcept; void error(std::wstring_view message) noexcept; } + +#endif // STEAMFIX_UI_HPP diff --git a/include/steamfix/win32.hpp b/include/steamfix/win32.hpp index e9dba17..4ece956 100644 --- a/include/steamfix/win32.hpp +++ b/include/steamfix/win32.hpp @@ -1,15 +1,26 @@ -#pragma once +#ifndef STEAMFIX_WIN32_HPP +#define STEAMFIX_WIN32_HPP #include +#include +#include #include #include #include #include +#include #include namespace steamfix { +// Windows process statuses are 32-bit bit patterns, including NTSTATUS values. +// Preserve those bits when crossing the signed C++ entry-point boundary. +inline int windows_exit_status(DWORD value) noexcept { + static_assert(sizeof(DWORD) == sizeof(int)); + return std::bit_cast(value); +} + class UniqueHandle { public: UniqueHandle() noexcept = default; @@ -18,7 +29,7 @@ class UniqueHandle { throw std::system_error(static_cast(GetLastError()), std::system_category()); } } - ~UniqueHandle() { reset(); } + ~UniqueHandle() noexcept { reset(); } UniqueHandle(const UniqueHandle&) = delete; UniqueHandle& operator=(const UniqueHandle&) = delete; @@ -47,16 +58,18 @@ class UniqueHandle { class LocalMemory { public: explicit LocalMemory(void* value = nullptr) noexcept : value_(value) {} - ~LocalMemory() { if (value_ != nullptr) LocalFree(value_); } + ~LocalMemory() noexcept { if (value_ != nullptr) LocalFree(value_); } LocalMemory(const LocalMemory&) = delete; LocalMemory& operator=(const LocalMemory&) = delete; + LocalMemory(LocalMemory&&) = delete; + LocalMemory& operator=(LocalMemory&&) = delete; private: void* value_; }; -inline std::string narrow(const std::wstring& value) { +inline std::string narrow(const std::wstring_view value) { if (value.empty()) return {}; - if (value.size() > static_cast(std::numeric_limits::max())) { + if (value.size() > static_cast(std::numeric_limits::max())) { throw std::length_error("wide string is too large to convert"); } int needed = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), @@ -68,7 +81,7 @@ inline std::string narrow(const std::wstring& value) { nullptr, 0, nullptr, nullptr); } if (needed <= 0) throw std::system_error(static_cast(GetLastError()), std::system_category()); - std::string result(static_cast(needed), '\0'); + std::string result(static_cast(needed), '\0'); if (WideCharToMultiByte(CP_UTF8, flags, value.data(), static_cast(value.size()), result.data(), needed, nullptr, nullptr) == 0) { throw std::system_error(static_cast(GetLastError()), std::system_category()); @@ -76,9 +89,9 @@ inline std::string narrow(const std::wstring& value) { return result; } -inline std::wstring widen(const std::string& value) { +inline std::wstring widen(const std::string_view value) { if (value.empty()) return {}; - if (value.size() > static_cast(std::numeric_limits::max())) { + if (value.size() > static_cast(std::numeric_limits::max())) { throw std::length_error("narrow string is too large to convert"); } int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), @@ -91,7 +104,7 @@ inline std::wstring widen(const std::string& value) { needed = MultiByteToWideChar(code_page, flags, value.data(), static_cast(value.size()), nullptr, 0); } if (needed <= 0) throw std::system_error(static_cast(GetLastError()), std::system_category()); - std::wstring result(static_cast(needed), L'\0'); + std::wstring result(static_cast(needed), L'\0'); if (MultiByteToWideChar(code_page, flags, value.data(), static_cast(value.size()), result.data(), needed) == 0) { throw std::system_error(static_cast(GetLastError()), std::system_category()); @@ -104,7 +117,9 @@ inline std::string display_path(const std::filesystem::path& value) { } inline std::string windows_error(DWORD code = GetLastError()) { - return std::system_error(static_cast(code), std::system_category()).what(); + return std::system_error(windows_exit_status(code), std::system_category()).what(); } } // namespace steamfix + +#endif // STEAMFIX_WIN32_HPP diff --git a/make.cmd b/make.cmd index afa2cc1..7549377 100644 --- a/make.cmd +++ b/make.cmd @@ -18,6 +18,9 @@ goto dispatch if /i "%task%"=="configure" goto configure if /i "%task%"=="build" goto build if /i "%task%"=="debug" goto debug +if /i "%task%"=="test" goto test +if /i "%task%"=="analyze" goto analyze +if /i "%task%"=="coverage" goto coverage if /i "%task%"=="clean" goto clean if /i "%task%"=="help" goto help if /i "%task%"=="--help" goto help @@ -40,10 +43,31 @@ call :do_configure || goto failure call :do_build Debug || goto failure goto success +:test +cmake -S . -B build -A x64 -DBUILD_TESTING=ON +if errorlevel 1 goto failure +call :do_build Debug || goto failure +ctest --test-dir build -C Debug --output-on-failure --no-tests=error +if errorlevel 1 goto failure +goto success + +:analyze +cmake -S . -B build-analysis -A x64 -DBUILD_TESTING=ON -DSTEAMFIX_ENABLE_CODE_ANALYSIS=ON +if errorlevel 1 goto failure +cmake --build build-analysis --config Debug --parallel +if errorlevel 1 goto failure +ctest --test-dir build-analysis -C Debug --output-on-failure --no-tests=error +if errorlevel 1 goto failure +goto success + :clean echo Removing build artifacts... cmake -E remove_directory "%CD%\build" if errorlevel 1 goto failure +cmake -E remove_directory "%CD%\build-analysis" +if errorlevel 1 goto failure +cmake -E remove_directory "%CD%\build-coverage" +if errorlevel 1 goto failure echo Runtime logs and the game-directory recovery journal are intentionally preserved. goto success @@ -55,7 +79,10 @@ echo Tasks: echo configure Generate a 64-bit Visual Studio build tree echo build Build the release executable echo debug Build the debug executable -echo clean Remove the build directory only +echo test Build and run the debug regression tests +echo analyze Build with C++ Core Check and run regression tests +echo coverage Measure Debug production line coverage with OpenCppCoverage +echo clean Remove build, analysis, and coverage directories echo help Show this help echo. if defined help_exit ( @@ -64,6 +91,15 @@ if defined help_exit ( ) goto success +:coverage +cmake -S . -B build-coverage -A x64 -DBUILD_TESTING=ON -DSTEAMFIX_ENABLE_COVERAGE=ON +if errorlevel 1 goto failure +cmake --build build-coverage --config Debug --parallel +if errorlevel 1 goto failure +powershell -NoProfile -ExecutionPolicy Bypass -File cmake\RunCoverage.ps1 -BuildDirectory build-coverage +if errorlevel 1 goto failure +goto success + :do_configure echo Configuring 64-bit Visual Studio build... cmake -S . -B build -A x64 diff --git a/src/acl.cpp b/src/acl.cpp index b1afe7f..992645a 100644 --- a/src/acl.cpp +++ b/src/acl.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include namespace fs = std::filesystem; @@ -57,58 +58,82 @@ void require_range(const std::span data, const size_t offset, } template + requires std::is_trivially_copyable_v T object_at(const std::span data, const size_t offset, const char* message) { require_range(data, offset, sizeof(T), message); T result{}; - std::memcpy(&result, data.data() + offset, sizeof(result)); + std::memcpy(&result, data.subspan(offset, sizeof(T)).data(), sizeof(result)); return result; } -void validate_relative_sid(const std::span data, const DWORD relative_offset) { +// These Win32 input parameters predate const-correct interfaces. Only pass the +// resulting pointer to APIs that document the pointed-to storage as input. +void* win32_input(const void* data) noexcept { + return const_cast(data); +} + +PSID sid_from_bytes(const std::span data) { constexpr size_t sid_header_size = offsetof(SID, SubAuthority); - const size_t offset = relative_offset; - if (offset < sizeof(SECURITY_DESCRIPTOR_RELATIVE) || offset % alignof(DWORD) != 0) { - throw std::runtime_error("invalid security descriptor SID offset"); - } - require_range(data, offset, sid_header_size, "truncated security descriptor SID"); - const auto revision = std::to_integer(data[offset]); - const auto count = std::to_integer(data[offset + 1]); + require_range(data, 0, sid_header_size, "truncated security descriptor SID"); + const auto revision = std::to_integer(data[0]); + const auto count = std::to_integer(data[1]); if (revision != SID_REVISION || count > SID_MAX_SUB_AUTHORITIES) { throw std::runtime_error("invalid security descriptor SID"); } const size_t length = checked_add(sid_header_size, static_cast(count) * sizeof(DWORD)); - require_range(data, offset, length, "truncated security descriptor SID"); - const auto sid = reinterpret_cast(const_cast(data.data() + offset)); + require_range(data, 0, length, "truncated security descriptor SID"); + const auto sid = win32_input(data.data()); if (!IsValidSid(sid) || GetLengthSid(sid) != length) { throw std::runtime_error("invalid security descriptor SID"); } + return sid; } -void validate_relative_acl(const std::span data, const DWORD relative_offset) { +void validate_relative_sid(const std::span data, const DWORD relative_offset) { const size_t offset = relative_offset; if (offset < sizeof(SECURITY_DESCRIPTOR_RELATIVE) || offset % alignof(DWORD) != 0) { - throw std::runtime_error("invalid security descriptor ACL offset"); + throw std::runtime_error("invalid security descriptor SID offset"); } - const ACL acl = object_at(data, offset, "truncated security descriptor ACL"); + require_range(data, offset, 0, "truncated security descriptor SID"); + static_cast(sid_from_bytes(data.subspan(offset))); +} + +void validate_acl(const std::span data) { + const ACL acl = object_at(data, 0, "truncated security descriptor ACL"); if (acl.AclSize < sizeof(ACL)) throw std::runtime_error("invalid security descriptor ACL size"); - const size_t end = checked_add(offset, acl.AclSize); - if (end > data.size()) throw std::runtime_error("truncated security descriptor ACL"); + require_range(data, 0, acl.AclSize, "truncated security descriptor ACL"); + const auto acl_bytes = data.first(acl.AclSize); - size_t ace_offset = checked_add(offset, sizeof(ACL)); + size_t ace_offset = sizeof(ACL); for (WORD index = 0; index < acl.AceCount; ++index) { const ACE_HEADER header = - object_at(data, ace_offset, "truncated security descriptor ACE"); - if (header.AceSize < sizeof(ACE_HEADER)) { + object_at(acl_bytes, ace_offset, "truncated security descriptor ACE"); + if (header.AceSize < sizeof(ACE_HEADER) || header.AceSize % alignof(DWORD) != 0) { throw std::runtime_error("invalid security descriptor ACE size"); } + require_range(acl_bytes, ace_offset, header.AceSize, "truncated security descriptor ACE"); + if (header.AceType == ACCESS_DENIED_ACE_TYPE || header.AceType == ACCESS_ALLOWED_ACE_TYPE) { + constexpr size_t sid_offset = offsetof(ACCESS_DENIED_ACE, SidStart); + if (header.AceSize < sid_offset) throw std::runtime_error("truncated security descriptor ACE"); + static_cast(sid_from_bytes( + acl_bytes.subspan(ace_offset + sid_offset, header.AceSize - sid_offset))); + } ace_offset = checked_add(ace_offset, header.AceSize); - if (ace_offset > end) throw std::runtime_error("truncated security descriptor ACE"); } - const auto acl_pointer = reinterpret_cast(const_cast(data.data() + offset)); + const auto acl_pointer = static_cast(win32_input(data.data())); if (!IsValidAcl(acl_pointer)) throw std::runtime_error("invalid security descriptor ACL"); } +void validate_relative_acl(const std::span data, const DWORD relative_offset) { + const size_t offset = relative_offset; + if (offset < sizeof(SECURITY_DESCRIPTOR_RELATIVE) || offset % alignof(DWORD) != 0) { + throw std::runtime_error("invalid security descriptor ACL offset"); + } + require_range(data, offset, 0, "truncated security descriptor ACL"); + validate_acl(data.subspan(offset)); +} + void validate_relative_descriptor(const std::span bytes) { const SECURITY_DESCRIPTOR_RELATIVE descriptor = object_at( bytes, 0, "truncated security descriptor"); @@ -130,8 +155,7 @@ void validate_relative_descriptor(const std::span bytes) { throw std::runtime_error("security descriptor has an unexpected DACL offset"); } - const auto raw = reinterpret_cast( - const_cast(bytes.data())); + const auto raw = win32_input(bytes.data()); if (!IsValidSecurityDescriptor(raw) || GetSecurityDescriptorLength(raw) != bytes.size()) { throw std::runtime_error("invalid security descriptor"); } @@ -145,8 +169,7 @@ struct DescriptorParts { DescriptorParts descriptor_parts(const std::span bytes) { validate_relative_descriptor(bytes); - const auto descriptor = reinterpret_cast( - const_cast(bytes.data())); + const auto descriptor = win32_input(bytes.data()); BOOL present = FALSE; BOOL defaulted = FALSE; PACL dacl = nullptr; @@ -171,8 +194,8 @@ PACL acl_from_bytes(const std::span bytes) { } const ACL header = object_at(bytes, 0, "truncated protected ACL"); if (header.AclSize != bytes.size()) throw std::runtime_error("invalid protected ACL length"); - auto* acl = reinterpret_cast(const_cast(bytes.data())); - if (!IsValidAcl(acl)) throw std::runtime_error("invalid protected ACL"); + validate_acl(bytes); + auto* acl = static_cast(win32_input(bytes.data())); return acl; } @@ -283,34 +306,49 @@ OpenBackup make_backup(const fs::path& path) { return {std::move(backup), std::move(opened.handle), opened.identity}; } -struct UserIdentity { - UniqueHandle token; - std::vector data; - PSID sid = nullptr; +class UserIdentity final { +public: + explicit UserIdentity(const std::span sid) + : data_(sid.begin(), sid.end()) { + static_cast(sid_from_bytes(data_)); + } + + [[nodiscard]] PSID sid() noexcept { return data_.data(); } + +private: + std::vector data_; }; UserIdentity current_user() { HANDLE raw = nullptr; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw)) throw_win32(GetLastError()); - UserIdentity result{UniqueHandle(raw), {}, nullptr}; + UniqueHandle token(raw); DWORD needed = 0; SetLastError(ERROR_SUCCESS); - static_cast(GetTokenInformation(result.token.get(), TokenUser, nullptr, 0, &needed)); + static_cast(GetTokenInformation(token.get(), TokenUser, nullptr, 0, &needed)); if (GetLastError() != ERROR_INSUFFICIENT_BUFFER || needed < sizeof(TOKEN_USER)) { const DWORD error = GetLastError(); if (error == ERROR_SUCCESS) throw std::runtime_error("invalid current-user token size"); throw_win32(error); } - result.data.resize(needed); - if (!GetTokenInformation(result.token.get(), TokenUser, result.data.data(), needed, &needed)) { + std::vector data(needed); + if (!GetTokenInformation(token.get(), TokenUser, data.data(), needed, &needed)) { throw_win32(GetLastError()); } - result.sid = reinterpret_cast(result.data.data())->User.Sid; - return result; + const TOKEN_USER user = object_at(data, 0, "truncated current-user token"); + if (!IsValidSid(user.User.Sid)) throw std::runtime_error("invalid current-user SID"); + return UserIdentity(std::span(static_cast(user.User.Sid), + GetLengthSid(user.User.Sid))); } class RestrictedSecurity final { public: + ~RestrictedSecurity() = default; + RestrictedSecurity(const RestrictedSecurity&) = delete; + RestrictedSecurity& operator=(const RestrictedSecurity&) = delete; + RestrictedSecurity(RestrictedSecurity&&) = delete; + RestrictedSecurity& operator=(RestrictedSecurity&&) = delete; + RestrictedSecurity(PSID user_sid, const DWORD inheritance_flags) { DWORD system_sid_size = static_cast(system_sid_.size()); if (!CreateWellKnownSid(WinLocalSystemSid, nullptr, system_sid_.data(), @@ -346,7 +384,7 @@ class RestrictedSecurity final { [[nodiscard]] SECURITY_ATTRIBUTES* attributes() noexcept { return &attributes_; } private: - std::array system_sid_{}; + alignas(DWORD) std::array system_sid_{}; std::vector acl_data_; SECURITY_DESCRIPTOR descriptor_{}; SECURITY_ATTRIBUTES attributes_{}; @@ -365,9 +403,11 @@ void require_current_user_owner(const HANDLE handle, PSID expected_owner) { } } -HANDLE open_access_raw(const fs::path& path, const DWORD access) { - return CreateFileW(path.c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE, - nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); +UniqueHandle probe_access(const fs::path& path, const DWORD access) { + const HANDLE raw = CreateFileW(path.c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (raw == INVALID_HANDLE_VALUE) return {}; + return UniqueHandle(raw); } void verify_denied(const Backup& backup, HANDLE handle); @@ -387,7 +427,7 @@ bool has_current_user_execute_deny(const Backup& backup, PSID sid) { (header->AceFlags & INHERITED_ACE) != 0) continue; const auto* denied = static_cast(ace); if ((denied->Mask & FILE_EXECUTE) != 0 && - EqualSid(const_cast(&denied->SidStart), sid)) return true; + EqualSid(win32_input(&denied->SidStart), sid)) return true; } return false; } @@ -435,17 +475,14 @@ std::vector make_denied_acl(const Backup& backup, PSID sid) { void verify_protection(const Backup& backup, const HANDLE handle) { verify_denied(backup, handle); - HANDLE readable_raw = open_access_raw(backup.path, GENERIC_READ); - if (readable_raw == INVALID_HANDLE_VALUE) { + const UniqueHandle readable = probe_access(backup.path, GENERIC_READ); + if (readable.get() == nullptr) { throw std::runtime_error("ordinary read access was unexpectedly blocked: " + windows_error(GetLastError())); } - UniqueHandle readable(readable_raw); - SetLastError(ERROR_SUCCESS); - const HANDLE executable = open_access_raw(backup.path, GENERIC_EXECUTE); - if (executable != INVALID_HANDLE_VALUE) { - CloseHandle(executable); + const UniqueHandle executable = probe_access(backup.path, GENERIC_EXECUTE); + if (executable.get() != nullptr) { throw std::runtime_error("execute access remained available after applying the deny ACE"); } const DWORD error = GetLastError(); @@ -664,7 +701,7 @@ std::vector protect_state(const std::span plaintext) throw std::runtime_error("invalid recovery plaintext size"); } DATA_BLOB input{static_cast(plaintext.size()), - reinterpret_cast(const_cast(plaintext.data()))}; + static_cast(win32_input(plaintext.data()))}; DATA_BLOB protected_data{}; if (!CryptProtectData(&input, L"D2SteamFix ACL recovery", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &protected_data)) { @@ -676,7 +713,8 @@ std::vector protect_state(const std::span plaintext) } std::vector output(state_magic.begin(), state_magic.end()); const auto begin = reinterpret_cast(protected_data.pbData); - output.insert(output.end(), begin, begin + protected_data.cbData); + const auto protected_bytes = std::span(begin, protected_data.cbData); + output.insert(output.end(), protected_bytes.begin(), protected_bytes.end()); return output; } @@ -685,7 +723,7 @@ std::vector unprotect_state(const std::span protecte throw std::runtime_error("invalid protected recovery size"); } DATA_BLOB input{static_cast(protected_data.size()), - reinterpret_cast(const_cast(protected_data.data()))}; + static_cast(win32_input(protected_data.data()))}; DATA_BLOB plaintext{}; if (!CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &plaintext)) { @@ -696,7 +734,8 @@ std::vector unprotect_state(const std::span protecte throw std::runtime_error("invalid recovery plaintext size"); } const auto begin = reinterpret_cast(plaintext.pbData); - return std::vector(begin, begin + plaintext.cbData); + const auto plaintext_bytes = std::span(begin, plaintext.cbData); + return std::vector(plaintext_bytes.begin(), plaintext_bytes.end()); } std::uint32_t take_u32(const std::span data, size_t& offset) { @@ -760,9 +799,8 @@ std::vector decode_payload(const std::span data) } const size_t descriptor_end = checked_add(offset, descriptor_length); if (descriptor_end > data.size()) throw std::runtime_error("truncated descriptor"); - std::vector descriptor( - data.begin() + static_cast(offset), - data.begin() + static_cast(descriptor_end)); + const auto descriptor_bytes = data.subspan(offset, descriptor_length); + std::vector descriptor(descriptor_bytes.begin(), descriptor_bytes.end()); offset = descriptor_end; static_cast(descriptor_parts(descriptor)); @@ -772,9 +810,8 @@ std::vector decode_payload(const std::span data) } const size_t denied_end = checked_add(offset, denied_length); if (denied_end > data.size()) throw std::runtime_error("truncated protected ACL"); - std::vector denied_acl( - data.begin() + static_cast(offset), - data.begin() + static_cast(denied_end)); + const auto denied_bytes = data.subspan(offset, denied_length); + std::vector denied_acl(denied_bytes.begin(), denied_bytes.end()); offset = denied_end; static_cast(acl_from_bytes(denied_acl)); result.push_back( @@ -799,7 +836,7 @@ void write_all(const HANDLE file, const std::span data) { const size_t remaining = data.size() - offset; const DWORD requested = static_cast(std::min(remaining, MAXDWORD)); DWORD written = 0; - if (!WriteFile(file, data.data() + offset, requested, &written, nullptr)) { + if (!WriteFile(file, data.subspan(offset, requested).data(), requested, &written, nullptr)) { throw_win32(GetLastError()); } if (written == 0) throw std::runtime_error("recovery file write made no progress"); @@ -824,6 +861,7 @@ void write_state(const fs::path& path, const std::span backups, file.reset(); elevation::move_recovery_file(temporary, path); } catch (...) { + file.reset(); try { elevation::remove_recovery_file(temporary); } catch (...) {} throw; } @@ -838,8 +876,8 @@ std::vector read_state(const fs::path& path) { throw std::runtime_error("recovery state is not a regular, non-reparse file"); } UserIdentity identity = current_user(); - require_current_user_owner(file.get(), identity.sid); - RestrictedSecurity security(identity.sid, 0); + require_current_user_owner(file.get(), identity.sid()); + RestrictedSecurity security(identity.sid(), 0); const DWORD acl_status = SetSecurityInfo( file.get(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, @@ -856,7 +894,8 @@ std::vector read_state(const fs::path& path) { const DWORD requested = static_cast( std::min(data.size() - offset, MAXDWORD)); DWORD read = 0; - if (!ReadFile(file.get(), data.data() + offset, requested, &read, nullptr)) { + if (!ReadFile(file.get(), std::span(data).subspan(offset, requested).data(), + requested, &read, nullptr)) { throw_win32(GetLastError()); } if (read == 0) throw std::runtime_error("could not read complete recovery file"); @@ -892,7 +931,6 @@ void remove_incomplete_state(const fs::path& path, const Logger& log) { } struct PreparedRecovery { - const RecoveryEntry* entry; OpenedTarget observed; OpenedTarget writable; }; @@ -900,19 +938,26 @@ struct PreparedRecovery { std::optional prepare_recovery_entry(const RecoveryEntry& entry, const Logger& log) { const std::string shown = display_path(entry.backup.path); - std::optional observed; - try { - observed = open_target(entry.backup.path, FILE_READ_ATTRIBUTES); - } catch (const UnsafeTarget& error) { - log.warn("skipped ACL recovery for unsafe renderer target: " + shown + ": " + error.what()); - return std::nullopt; - } catch (const std::system_error& error) { - if (missing_error(static_cast(error.code().value()))) { - log.warn("skipped ACL recovery for missing renderer: " + shown); + auto observed = [&]() -> std::optional { + try { + return open_target(entry.backup.path, FILE_READ_ATTRIBUTES); + } catch (const UnsafeTarget& error) { + log.warn("skipped ACL recovery for unsafe renderer target: " + shown + ": " + error.what()); return std::nullopt; + } catch (const std::system_error& error) { + if (missing_error(static_cast(error.code().value()))) { + log.warn("skipped ACL recovery for missing renderer: " + shown); + return std::nullopt; + } + throw; } - throw; - } + }(); + if (!observed) return std::nullopt; + // Core Check loses the optional aggregate's identity after the factory + // return. It is engaged here and owns its handle and scalar identity. +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif if (observed->identity != entry.identity) { log.warn("skipped ACL recovery because renderer identity changed: " + shown); return std::nullopt; @@ -923,11 +968,17 @@ std::optional prepare_recovery_entry(const RecoveryEntry& entr } OpenedTarget writable = open_target( entry.backup.path, READ_CONTROL | WRITE_DAC | FILE_READ_ATTRIBUTES); +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif if (writable.identity != entry.identity || writable.identity != observed->identity) { throw std::runtime_error("renderer identity changed while ACL recovery was opening it: " + shown); } static_cast(restoration_state(entry.backup, writable.handle.get())); - return PreparedRecovery{&entry, std::move(*observed), std::move(writable)}; +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif + return PreparedRecovery{std::move(*observed), std::move(writable)}; } } // namespace @@ -955,6 +1006,11 @@ bool recover_pending(const fs::path& path, const Logger& log) { for (const RecoveryEntry& entry : entries) { std::optional target = prepare_recovery_entry(entry, log); if (target) { + // The engaged optional owns both handles; moving its aggregate + // transfers ownership without borrowing from either vector. +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif prepared.push_back(std::move(*target)); } else { ++skipped; @@ -968,8 +1024,10 @@ bool recover_pending(const fs::path& path, const Logger& log) { "one or more renderer files could not be safely matched; recovery state was preserved"); } size_t restored = 0; - for (const PreparedRecovery& target : prepared) { - if (restore_one(target.entry->backup, target.writable.handle.get())) ++restored; + // A skipped entry aborts above, so successful preflight preserves the + // journal order without borrowing pointers into the entries vector. + for (size_t index = 0; index < prepared.size(); ++index) { + if (restore_one(entries[index].backup, prepared[index].writable.handle.get())) ++restored; } const bool removed = elevation::remove_recovery_file(path); if (!removed) throw std::runtime_error("recovery file disappeared before cleanup"); @@ -983,12 +1041,16 @@ bool recover_pending(const fs::path& path, const Logger& log) { } } -Protection Protection::apply(const std::vector& targets, fs::path state_path, +Protection Protection::apply(const std::span targets, fs::path state_path, const Logger& log) { if (targets.empty() || targets.size() > max_entries) { throw AppError(ExitCode::overlay_protection, "ACL backup", "expected one or two renderer targets"); } + if (targets.size() == 2 && paths::equal_windows(targets.front(), targets.back())) { + throw AppError(ExitCode::overlay_protection, "ACL backup", + "renderer targets must refer to distinct files"); + } std::vector backups; std::vector handles; std::vector identities; @@ -999,16 +1061,22 @@ Protection Protection::apply(const std::vector& targets, fs::path stat identities.reserve(targets.size()); resuming = fs::exists(state_path); if (resuming) { - std::vector entries = read_state(state_path); + const std::vector entries = read_state(state_path); if (entries.size() != targets.size()) { throw std::runtime_error( "saved renderer set does not match the selected Steam renderer files; " "original recovery backup was preserved"); } std::vector matched(entries.size(), false); + // Core Check mistakes the returned records' owning filesystem paths + // for borrowed storage. This const vector remains alive and unchanged + // through the loop; copying a Backup also copies its owning members. for (const fs::path& target : targets) { size_t match = entries.size(); for (size_t index = 0; index < entries.size(); ++index) { +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif if (!paths::equal_windows(target, entries[index].backup.path)) continue; if (matched[index] || match != entries.size()) { throw std::runtime_error("saved renderer set contains duplicate targets"); @@ -1020,13 +1088,18 @@ Protection Protection::apply(const std::vector& targets, fs::path stat "recovery backup: " + display_path(target)); } try { - std::optional prepared = - prepare_recovery_entry(entries[match], log); +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif + auto prepared = prepare_recovery_entry(entries[match], log); if (!prepared) { throw std::runtime_error("renderer could not be safely matched to its " "original recovery backup"); } matched[match] = true; +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif backups.push_back(entries[match].backup); handles.push_back(std::move(prepared->writable.handle)); } catch (const elevation::Cancelled&) { @@ -1053,16 +1126,29 @@ Protection Protection::apply(const std::vector& targets, fs::path stat throw protection_error("current-user identity", error); } }(); + // Backup owns its path and byte vectors. The factory/copies/moves above + // transfer that storage; backups is not resized or moved again until return. + // Suppress only Core Check's false borrowed-path diagnostics at their uses. try { if (!resuming) { for (Backup& backup : backups) { - if (has_current_user_execute_deny(backup, identity.sid)) { +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif + if (has_current_user_execute_deny(backup, identity.sid())) { +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif + const std::string shown = display_path(backup.path); throw std::runtime_error( "current-user execute access is already denied but no matching recovery " "backup was found; preserve any existing steamfix.dat files: " + - display_path(backup.path)); + shown); } - backup.denied_acl = make_denied_acl(backup, identity.sid); +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif + backup.denied_acl = make_denied_acl(backup, identity.sid()); } } } catch (const std::exception& error) { @@ -1071,7 +1157,7 @@ Protection Protection::apply(const std::vector& targets, fs::path stat try { if (!resuming) { remove_incomplete_state(state_path, log); - write_state(state_path, backups, identities, identity.sid); + write_state(state_path, backups, identities, identity.sid()); } } catch (const AppError&) { throw; @@ -1081,14 +1167,25 @@ Protection Protection::apply(const std::vector& targets, fs::path stat if (resuming) log.info("reusing original renderer permissions from existing recovery backup"); for (size_t index = 0; index < backups.size(); ++index) { try { +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif + const std::string shown = display_path(backups[index].path); +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif if (restoration_state(backups[index], handles[index].get()) == RestorationState::denied) { +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif verify_protection(backups[index], handles[index].get()); - log.info("retained current-user execute denial from interrupted session: " + - display_path(backups[index].path)); + log.info("retained current-user execute denial from interrupted session: " + shown); } else { +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif deny(backups[index], handles[index].get()); - log.info("temporarily denied current-user execute access: " + - display_path(backups[index].path)); + log.info("temporarily denied current-user execute access: " + shown); } } catch (const std::exception& error) { bool restored = false; @@ -1125,13 +1222,13 @@ void Protection::restore(const Logger& log) { log.info("restored original renderer permissions and removed recovery state"); } -Protection::~Protection() { +Protection::~Protection() noexcept { if (state_ != State::active) return; try { restore_all(backups_, handles_); elevation::remove_recovery_file(state_path_); state_ = State::restored; - } catch (const std::exception&) { + } catch (...) { } } diff --git a/src/app.cpp b/src/app.cpp index 6505c1a..628ae92 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -15,11 +15,15 @@ #include +#include +#include #include -#include #include +#include #include +#include #include +#include #include #include #include @@ -29,7 +33,7 @@ namespace fs = std::filesystem; namespace steamfix::app { namespace { -constexpr wchar_t invalid_message[] = +constexpr std::wstring_view invalid_message = L"D2SteamFix was not started by Steam with a Destiny 2 command.\n\n" L"This program must be run from Steam using launch options:\n" L"\"C:\\path\\to\\steamfix.exe\" %command%\n\nDestiny 2 was not started."; @@ -76,9 +80,13 @@ std::vector command_line_arguments() { int count = 0; LPWSTR* raw = CommandLineToArgvW(GetCommandLineW(), &count); if (raw == nullptr) throw std::system_error(static_cast(GetLastError()), std::system_category()); - const std::unique_ptr guard(raw, &LocalFree); + const LocalMemory guard(raw); + if (count < 1) throw std::runtime_error("Windows returned an empty command line"); + // CommandLineToArgvW owns one LocalFree block containing count pointers. + const std::span arguments(raw, static_cast(count)); std::vector result; - for (int index = 1; index < count; ++index) result.emplace_back(raw[index]); + result.reserve(arguments.size() - 1); + for (const auto argument : arguments.subspan(1)) result.emplace_back(argument); return result; } @@ -98,13 +106,15 @@ class SessionMutex final { ~SessionMutex() { ReleaseMutex(handle_.get()); } SessionMutex(const SessionMutex&) = delete; SessionMutex& operator=(const SessionMutex&) = delete; + SessionMutex(SessionMutex&&) = delete; + SessionMutex& operator=(SessionMutex&&) = delete; private: UniqueHandle handle_; }; SessionMutex session_mutex() { SetLastError(ERROR_SUCCESS); - HANDLE raw = CreateMutexW(nullptr, TRUE, L"Global\\D2SteamFix-1085660"); + const HANDLE raw = CreateMutexW(nullptr, TRUE, L"Global\\D2SteamFix-1085660"); if (raw == nullptr) { throw AppError(ExitCode::existing_session, "single-instance check", windows_error()); } @@ -188,15 +198,17 @@ ChildProcess launch(const Invocation& invocation, const fs::path& steam_director std::string existing_processes(const std::vector>& existing) { std::ostringstream output; output << "verified Destiny processes already running: ["; - for (size_t index = 0; index < existing.size(); ++index) { - if (index != 0) output << ", "; - output << '(' << existing[index].first << ", " << narrow(existing[index].second) << ')'; + std::string_view separator; + for (const auto& [pid, name] : existing) { + output << separator << '(' << pid << ", " << narrow(name) << ')'; + separator = ", "; } output << ']'; return output.str(); } -int run(const Logger& log, const std::vector& forwarded, const fs::path& recovery) { +int run(const Logger& log, const std::vector& forwarded, const fs::path& recovery, + const std::optional& inherited_steam_directory) { const std::optional app_id = environment(L"SteamAppId"); const std::optional game_id = environment(L"SteamGameId"); log.info(narrow(L"inherited App IDs: SteamAppId=" + optional_id(app_id) + @@ -206,8 +218,8 @@ int run(const Logger& log, const std::vector& forwarded, const fs: log.info("relevant App ID: 1085660"); fs::path steam_directory; try { - if (const std::optional inherited = elevation::inherited_steam_directory()) { - steam_directory = *inherited; + if (inherited_steam_directory) { + steam_directory = *inherited_steam_directory; } else { steam_directory = steam::discover(); } @@ -242,12 +254,12 @@ int run(const Logger& log, const std::vector& forwarded, const fs: log.info("selected renderer DLL: " + display_path(renderer64)); const fs::path renderer32 = steam_directory / L"GameOverlayRenderer.dll"; std::error_code renderer32_error; - const bool renderer32_exists = fs::is_regular_file(renderer32, renderer32_error); - if (renderer32_error) { + const fs::file_status renderer32_status = fs::status(renderer32, renderer32_error); + if (renderer32_error && renderer32_status.type() != fs::file_type::not_found) { throw AppError(ExitCode::steam_discovery, "renderer discovery", display_path(renderer32) + ": " + renderer32_error.message()); } - if (renderer32_exists) { + if (fs::is_regular_file(renderer32_status)) { log.info("selected renderer DLL: " + display_path(renderer32)); targets.push_back(renderer32); } else { @@ -379,14 +391,25 @@ Invocation validate_invocation(const std::vector& args, return {launcher, game_dir, std::vector(args.begin() + 1, args.end())}; } +Logger create_log(const fs::path& path) { + try { + return Logger(path); + } catch (const elevation::Cancelled&) { + throw; + } catch (const std::exception& error) { + throw AppError(ExitCode::log_initialization, "log initialization", error.what()); + } +} + } // namespace -int entry() { +int entry() noexcept try { std::vector forwarded; + std::optional inherited_steam_directory; try { forwarded = command_line_arguments(); if (const std::optional helper = elevation::dispatch_helper(forwarded)) return *helper; - elevation::accept_elevated_launch(forwarded); + inherited_steam_directory = elevation::accept_elevated_launch(forwarded); } catch (const std::exception& error) { ui::error(L"D2SteamFix could not read its command line: " + widen(error.what())); return static_cast(ExitCode::invalid_invocation); @@ -411,74 +434,85 @@ int entry() { L"recovery files.\n\n" + widen(error.what()) + L"\n\nDestiny 2 was not started."); return static_cast(ExitCode::log_initialization); } - std::unique_ptr log; - try { - log = std::make_unique(game_directory / L"steamfix.log"); - } catch (const elevation::Cancelled&) { - return 0; - } catch (const std::exception& error) { - ui::error(L"D2SteamFix could not open steamfix.log in the Destiny 2 directory. " - L"Ensure the game directory is writable.\n\n" + widen(error.what()) + - L"\n\nDestiny 2 was not started."); - return static_cast(ExitCode::log_initialization); - } - log->info("startup; wrapper path: " + display_path(executable) + + const Logger log = create_log(game_directory / L"steamfix.log"); + log.info("startup; wrapper path: " + display_path(executable) + "; game directory: " + display_path(game_directory)); try { const fs::path recovery = acl::recovery_path(game_directory); - log->info("ACL recovery state path: " + display_path(recovery)); + log.info("ACL recovery state path: " + display_path(recovery)); if (forwarded.empty()) { - static_cast(repair::run(game_directory, recovery, *log)); - log->info("direct Steam repair finished; final exit status: 0"); + static_cast(repair::run(game_directory, recovery, log)); + log.info("direct Steam repair finished; final exit status: 0"); return 0; } const SessionMutex mutex = session_mutex(); - const int code = run(*log, forwarded, recovery); - log->info("final exit status: " + std::to_string(code)); + const int code = run(log, forwarded, recovery, inherited_steam_directory); + log.info("final exit status: " + std::to_string(code)); return code; } catch (const elevation::RelaunchRequired& request) { // The protected session and its mutex have both unwound before UAC. try { - log->info("original renderer permissions restored; requesting an administrator D2SteamFix session"); + log.info("original renderer permissions restored; requesting an administrator D2SteamFix session"); std::vector retry = forwarded; // Resolve a relative Steam command once, in its original directory. retry.front() = request.launcher().native(); const std::optional code = elevation::relaunch_as_administrator(retry, request.directory(), request.steam_directory()); if (code) { - log->info("administrator D2SteamFix session exit status: " + std::to_string(*code)); + log.info("administrator D2SteamFix session exit status: " + std::to_string(*code)); } else { - log->warn("administrator D2SteamFix session exited; Windows did not provide its exit status"); + log.warn("administrator D2SteamFix session exited; Windows did not provide its exit status"); } return code.value_or(0); } catch (const elevation::Cancelled&) { - log->info("Windows permission request cancelled; original permissions remain restored"); + log.info("Windows permission request cancelled; original permissions remain restored"); return 0; } catch (const std::exception& error) { - log->error("administrator session launch or monitoring failed: " + std::string(error.what())); + log.error("administrator session launch or monitoring failed: " + std::string(error.what())); ui::error(L"D2SteamFix could not complete its administrator session.\n\n" + widen(error.what()) + L"\n\nOriginal renderer permissions were restored before the administrator session was requested."); return static_cast(ExitCode::process_creation); } } catch (const elevation::Cancelled&) { - log->info("Windows permission request cancelled; no launcher was started"); + log.info("Windows permission request cancelled; no launcher was started"); return 0; } catch (const AppError& error) { - log->error(std::string(error.what()) + "; helper exit code=" + + log.error(std::string(error.what()) + "; helper exit code=" + std::to_string(static_cast(error.code()))); ui::error(failure_message(error)); return static_cast(error.code()); } catch (const std::exception& error) { - log->error("unexpected internal failure: " + std::string(error.what()) + "; helper exit code=19"); + log.error("unexpected internal failure: " + std::string(error.what()) + "; helper exit code=19"); ui::error(L"D2SteamFix encountered an unexpected internal failure.\n\nSee steamfix.log in the " L"Destiny 2 directory.\n\nDestiny 2 was not started."); return static_cast(ExitCode::internal); } catch (...) { - log->error("unexpected internal failure; helper exit code=19"); + log.error("unexpected internal failure; helper exit code=19"); ui::error(L"D2SteamFix encountered an unexpected internal failure.\n\nSee steamfix.log in the " L"Destiny 2 directory.\n\nDestiny 2 was not started."); return static_cast(ExitCode::internal); } +} catch (const elevation::Cancelled&) { + return 0; +} catch (const AppError& error) { + try { + if (error.code() == ExitCode::log_initialization) { + ui::error(L"D2SteamFix could not open steamfix.log in the Destiny 2 directory. " + L"Ensure the game directory is writable.\n\n" + widen(error.detail()) + + L"\n\nDestiny 2 was not started."); + } else { + ui::error(failure_message(error)); + } + } catch (...) { + ui::error(L"D2SteamFix could not report an initialization failure."); + } + return static_cast(error.code()); +} catch (...) { + // Error formatting and startup logging can allocate too. Keep every failure + // inside the C++ boundary and use an allocation-free last-resort message. + ui::error(L"D2SteamFix encountered an unexpected failure.\n\n" + L"If a session was interrupted, keep steamfix.dat for permission recovery."); + return static_cast(ExitCode::internal); } } // namespace steamfix::app diff --git a/src/command_line.cpp b/src/command_line.cpp index 3b70b06..19e9b72 100644 --- a/src/command_line.cpp +++ b/src/command_line.cpp @@ -1,5 +1,7 @@ #include "steamfix/command_line.hpp" +#include + namespace steamfix::command_line { void append_quoted(std::wstring& line, const std::wstring_view argument) { @@ -9,7 +11,7 @@ void append_quoted(std::wstring& line, const std::wstring_view argument) { return; } line.push_back(L'\"'); - size_t slashes = 0; + std::size_t slashes = 0; for (const wchar_t character : argument) { if (character == L'\\') { ++slashes; diff --git a/src/elevation.cpp b/src/elevation.cpp index d40fb12..13adc7c 100644 --- a/src/elevation.cpp +++ b/src/elevation.cpp @@ -11,22 +11,27 @@ #include #include +#include +#include #include #include #include #include #include +#include #include +#include +#include namespace fs = std::filesystem; namespace steamfix::elevation { namespace { -constexpr wchar_t helper_switch[] = L"--steamfix-permission-helper"; -constexpr wchar_t elevated_launch_switch[] = L"--steamfix-elevated-launch"; -std::optional accepted_steam_directory; +constexpr std::wstring_view helper_switch = L"--steamfix-permission-helper"; +constexpr std::wstring_view elevated_launch_switch = L"--steamfix-elevated-launch"; constexpr DWORD protocol = 0x31584653; constexpr DWORD max_path_units = 32767; +constexpr DWORD pipe_timeout = 60000; constexpr DWORD file_limit = READ_CONTROL | WRITE_DAC | DELETE | FILE_READ_ATTRIBUTES | FILE_READ_DATA | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_ATTRIBUTES | SYNCHRONIZE | GENERIC_READ | GENERIC_WRITE; @@ -45,12 +50,24 @@ struct Request { }; struct Response { DWORD error = ERROR_SUCCESS; + DWORD reserved = 0; std::uintptr_t handle = 0; }; +// The private helper is the same 64-bit executable. Keep its wire layout +// explicit, including the former padding before the duplicated handle. +static_assert(sizeof(Request) == 36 && sizeof(Response) == 16); +static_assert(offsetof(Response, handle) == 8); +static_assert(std::is_trivially_copyable_v && std::is_trivially_copyable_v); +static_assert(std::has_unique_object_representations_v && + std::has_unique_object_representations_v); + +template +concept WireObject = std::same_as || std::same_as; + [[noreturn]] void fail(const DWORD error) { if (error == ERROR_CANCELLED) throw Cancelled(); - throw std::system_error(static_cast(error), std::system_category()); + throw std::system_error(windows_exit_status(error), std::system_category()); } fs::path executable_path(HANDLE process = nullptr) { @@ -66,18 +83,25 @@ fs::path executable_path(HANDLE process = nullptr) { return fs::path(value); } -void transfer(const HANDLE pipe, void* data, const size_t size, const bool writing, const DWORD timeout = 60000) { - auto* bytes = static_cast(data); - size_t offset = 0; +template + requires std::same_as || std::same_as +void transfer(const HANDLE pipe, const std::span data, const DWORD timeout = pipe_timeout) { UniqueHandle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); - while (offset < size) { - const DWORD requested = static_cast(std::min(size - offset, MAXDWORD)); + std::size_t offset = 0; + while (offset < data.size()) { + const auto bytes = data.subspan(offset); + const DWORD requested = static_cast(std::min(bytes.size(), MAXDWORD)); DWORD completed = 0; OVERLAPPED overlap{}; overlap.hEvent = event.get(); if (!ResetEvent(event.get())) fail(GetLastError()); - const BOOL result = writing ? WriteFile(pipe, bytes + offset, requested, &completed, &overlap) - : ReadFile(pipe, bytes + offset, requested, &completed, &overlap); + const BOOL result = [&] { + if constexpr (std::is_const_v) { + return WriteFile(pipe, bytes.data(), requested, &completed, &overlap); + } else { + return ReadFile(pipe, bytes.data(), requested, &completed, &overlap); + } + }(); if (!result) { if (GetLastError() != ERROR_IO_PENDING) fail(GetLastError()); const DWORD waited = WaitForSingleObject(event.get(), timeout); @@ -91,19 +115,20 @@ void transfer(const HANDLE pipe, void* data, const size_t size, const bool writi if (!GetOverlappedResult(pipe, &overlap, &completed, FALSE)) fail(GetLastError()); } if (completed == 0) fail(ERROR_BROKEN_PIPE); + if (completed > requested) fail(ERROR_INVALID_DATA); offset += completed; } } -template +template void write_object(const HANDLE pipe, const T& object) { - transfer(pipe, const_cast(&object), sizeof(object), true); + transfer(pipe, std::as_bytes(std::span{&object, 1})); } -template -T read_object(const HANDLE pipe, const DWORD timeout = 60000) { +template +T read_object(const HANDLE pipe, const DWORD timeout = pipe_timeout) { T result{}; - transfer(pipe, &result, sizeof(result), false, timeout); + transfer(pipe, std::as_writable_bytes(std::span{&result, 1}), timeout); return result; } @@ -119,8 +144,13 @@ std::vector token_user(const HANDLE process) { return result; } -PSID user_sid(std::vector& user) { - return reinterpret_cast(user.data())->User.Sid; +PSID user_sid(const std::vector& user) { + if (user.size() < sizeof(TOKEN_USER)) fail(ERROR_INVALID_DATA); + TOKEN_USER information{}; + // GetTokenInformation places a native header followed by its SID in the + // byte buffer. Copy the header instead of dereferencing byte storage as T. + std::memcpy(&information, user.data(), sizeof(information)); + return information.User.Sid; } std::wstring sid_string(PSID sid) { @@ -130,23 +160,6 @@ std::wstring sid_string(PSID sid) { return text; } -void enable_privilege(const wchar_t* name) { - HANDLE raw = nullptr; - if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &raw)) { - fail(GetLastError()); - } - const UniqueHandle token(raw); - TOKEN_PRIVILEGES privileges{}; - privileges.PrivilegeCount = 1; - if (!LookupPrivilegeValueW(nullptr, name, &privileges.Privileges[0].Luid)) { - fail(GetLastError()); - } - privileges.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; - SetLastError(ERROR_SUCCESS); - if (!AdjustTokenPrivileges(token.get(), FALSE, &privileges, 0, nullptr, nullptr) || - GetLastError() != ERROR_SUCCESS) fail(GetLastError()); -} - class ScopedPrivilege final { public: explicit ScopedPrivilege(const wchar_t* name) { @@ -162,23 +175,34 @@ class ScopedPrivilege final { if (!AdjustTokenPrivileges(token_.get(), FALSE, &requested, sizeof(previous_), &previous_, &returned) || GetLastError() != ERROR_SUCCESS) fail(GetLastError()); } - ~ScopedPrivilege() { + ~ScopedPrivilege() noexcept { const DWORD saved_error = GetLastError(); AdjustTokenPrivileges(token_.get(), FALSE, &previous_, 0, nullptr, nullptr); + token_.reset(); SetLastError(saved_error); } ScopedPrivilege(const ScopedPrivilege&) = delete; ScopedPrivilege& operator=(const ScopedPrivilege&) = delete; + ScopedPrivilege(ScopedPrivilege&&) = delete; + ScopedPrivilege& operator=(ScopedPrivilege&&) = delete; private: UniqueHandle token_; TOKEN_PRIVILEGES previous_{}; }; +std::mutex& privilege_mutex() { + // Token privileges belong to the process. Keep save/restore scopes from + // overlapping across callers, even when they request the same privilege. + static std::mutex mutex; + return mutex; +} + UniqueHandle process_with_debug_retry(const DWORD access, const DWORD pid) { HANDLE raw = OpenProcess(access, FALSE, pid); if (raw != nullptr) return UniqueHandle(raw); const DWORD error = GetLastError(); if (!permission_denied(error)) fail(error); + const std::lock_guard privilege_lock(privilege_mutex()); const ScopedPrivilege privilege(SE_DEBUG_NAME); raw = OpenProcess(access, FALSE, pid); if (raw == nullptr) fail(GetLastError()); @@ -267,22 +291,36 @@ UniqueHandle checked_file(const fs::path& path, const DWORD access, const DWORD if (final_path.starts_with(L"\\\\?\\")) final_path.erase(0, 4); expected = fs::path(expected).lexically_normal().native(); final_path = fs::path(final_path).lexically_normal().native(); + if (expected.size() > static_cast(std::numeric_limits::max())) fail(ERROR_BAD_PATHNAME); if (CompareStringOrdinal(expected.c_str(), static_cast(expected.size()), final_path.c_str(), static_cast(final_path.size()), TRUE) != CSTR_EQUAL) fail(ERROR_BAD_PATHNAME); return file; } -void rename_without_replacement(const HANDLE file, const fs::path& target) { +std::vector rename_information(const fs::path& target) { std::wstring destination = target.native(); if (destination.starts_with(L"\\\\?\\")) destination.erase(0, 4); - const size_t name_bytes = destination.size() * sizeof(wchar_t); - std::vector storage(offsetof(FILE_RENAME_INFO, FileName) + name_bytes); - auto* rename = reinterpret_cast(storage.data()); - rename->ReplaceIfExists = FALSE; - rename->RootDirectory = nullptr; - rename->FileNameLength = static_cast(name_bytes); - std::memcpy(rename->FileName, destination.data(), name_bytes); - if (!SetFileInformationByHandle(file, FileRenameInfo, rename, + constexpr std::size_t header_size = offsetof(FILE_RENAME_INFO, FileName); + constexpr std::size_t terminator_size = sizeof(wchar_t); + if (destination.size() > (MAXDWORD - header_size - terminator_size) / sizeof(wchar_t)) fail(ERROR_BAD_PATHNAME); + const std::size_t name_bytes = destination.size() * sizeof(wchar_t); + const DWORD length = static_cast(name_bytes); + std::vector storage(std::max(sizeof(FILE_RENAME_INFO), header_size + name_bytes + terminator_size)); + // Win32 uses zero for ReplaceIfExists=false and RootDirectory=nullptr. + // Keep their entire native header, including union and alignment bytes, + // zeroed instead of copying indeterminate C++ structure padding into it. + std::memcpy(std::span{storage}.subspan(offsetof(FILE_RENAME_INFO, FileNameLength), sizeof(length)).data(), + &length, sizeof(length)); + std::ranges::copy(std::as_bytes(std::span{destination}), + std::span{storage}.subspan(header_size, name_bytes).begin()); + // SetFileInformationByHandle also uses the trailing NUL when converting + // the absolute DOS path, although FileNameLength excludes that terminator. + return storage; +} + +void rename_without_replacement(const HANDLE file, const fs::path& target) { + std::vector storage = rename_information(target); + if (!SetFileInformationByHandle(file, FileRenameInfo, storage.data(), static_cast(storage.size()))) fail(GetLastError()); } @@ -310,12 +348,15 @@ UniqueHandle privileged_file(const Request& request, const fs::path& path, PSID } if (!journal && !log && (request.access & (GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_ATTRIBUTES)) != 0) fail(ERROR_ACCESS_DENIED); + const std::lock_guard privilege_lock(privilege_mutex()); + std::optional backup; + std::optional restore; PSECURITY_DESCRIPTOR descriptor = nullptr; SECURITY_ATTRIBUTES security{sizeof(security), nullptr, FALSE}; // A credential prompt may use a different administrator account. The backup // remains owned/readable by its originating user, who encrypts it with DPAPI. if (request.creation == CREATE_NEW || (log && request.creation == OPEN_ALWAYS)) { - enable_privilege(SE_RESTORE_NAME); + restore.emplace(SE_RESTORE_NAME); const std::wstring sid = sid_string(originator); const std::wstring sddl = L"O:" + sid + L"D:P(A;;FA;;;" + sid + L")(A;;FA;;;SY)"; if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( @@ -327,8 +368,8 @@ UniqueHandle privileged_file(const Request& request, const fs::path& path, PSID if (journal || log) { // A different administrator may approve UAC. Backup semantics let the // helper open the originating user's private journal without adding an ACE. - enable_privilege(SE_BACKUP_NAME); - enable_privilege(SE_RESTORE_NAME); + backup.emplace(SE_BACKUP_NAME); + if (!restore) restore.emplace(SE_RESTORE_NAME); extra_flags = FILE_FLAG_BACKUP_SEMANTICS; } UniqueHandle file = checked_file(path, request.access | READ_CONTROL, request.share, @@ -348,12 +389,14 @@ bool oversized_log(const HANDLE file) { } bool rotate_existing_log(const fs::path& path, const bool privileged) { + std::optional> privilege_lock; std::optional backup; std::optional restore; DWORD flags = FILE_FLAG_OPEN_REPARSE_POINT; if (privileged) { validate_path(path); if (!game_log(path)) fail(ERROR_ACCESS_DENIED); + privilege_lock.emplace(privilege_mutex()); backup.emplace(SE_BACKUP_NAME); restore.emplace(SE_RESTORE_NAME); flags |= FILE_FLAG_BACKUP_SEMANTICS; @@ -406,9 +449,9 @@ UniqueHandle privileged_process(const DWORD pid) { if (paths::name_is(image, L"steamservice.exe")) { constexpr std::array folders{CSIDL_PROGRAM_FILES_COMMONX86, CSIDL_PROGRAM_FILES_COMMON}; for (const int folder : folders) { - wchar_t directory[MAX_PATH]{}; - if (SUCCEEDED(SHGetFolderPathW(nullptr, folder, nullptr, SHGFP_TYPE_CURRENT, directory)) && - paths::equal_windows(image, fs::path(directory) / L"Steam" / L"SteamService.exe")) return process; + std::array directory{}; + if (SUCCEEDED(SHGetFolderPathW(nullptr, folder, nullptr, SHGFP_TYPE_CURRENT, directory.data())) && + paths::equal_windows(image, fs::path(directory.data()) / L"Steam" / L"SteamService.exe")) return process; } } if (steam) { @@ -426,7 +469,7 @@ UniqueHandle privileged_process(const DWORD pid) { std::wstring read_path(const HANDLE pipe, const DWORD units) { if (units > max_path_units) fail(ERROR_INVALID_DATA); std::wstring value(units, L'\0'); - transfer(pipe, value.data(), value.size() * sizeof(wchar_t), false); + transfer(pipe, std::as_writable_bytes(std::span{value})); return value; } @@ -444,10 +487,20 @@ class Broker final { if (text.size() > max_path_units || second_text.size() > max_path_units) fail(ERROR_BAD_PATHNAME); request.path_units = static_cast(text.size()); request.second_path_units = static_cast(second_text.size()); - write_object(pipe_.get(), request); - transfer(pipe_.get(), const_cast(text.data()), text.size() * sizeof(wchar_t), true); - transfer(pipe_.get(), const_cast(second_text.data()), second_text.size() * sizeof(wchar_t), true); - const Response response = read_object(pipe_.get()); + const Response response = [&] { + try { + write_object(pipe_.get(), request); + transfer(pipe_.get(), std::as_bytes(std::span{text})); + transfer(pipe_.get(), std::as_bytes(std::span{second_text})); + return read_object(pipe_.get()); + } catch (...) { + // A partial exchange has lost its framing. Let the helper exit + // and obtain a new connection for a later independent request. + pipe_.reset(); + helper_.reset(); + throw; + } + }(); if (response.error != ERROR_SUCCESS) fail(response.error); return response; } @@ -455,9 +508,9 @@ class Broker final { void start() { GUID identifier{}; if (FAILED(CoCreateGuid(&identifier))) fail(ERROR_GEN_FAILURE); - wchar_t guid[40]{}; - if (StringFromGUID2(identifier, guid, static_cast(std::size(guid))) == 0) fail(ERROR_GEN_FAILURE); - const std::wstring pipe_name = L"\\\\.\\pipe\\D2SteamFix-" + std::wstring(guid); + std::array guid{}; + if (StringFromGUID2(identifier, guid.data(), static_cast(guid.size())) == 0) fail(ERROR_GEN_FAILURE); + const std::wstring pipe_name = L"\\\\.\\pipe\\D2SteamFix-" + std::wstring(guid.data()); std::vector user = token_user(GetCurrentProcess()); const std::wstring sddl = L"D:P(A;;GA;;;" + sid_string(user_sid(user)) + L")(A;;GA;;;BA)(A;;GA;;;SY)"; @@ -481,17 +534,26 @@ class Broker final { const bool pending = !connecting && connection_error == ERROR_IO_PENDING; if (!pending && !SetEvent(connected.get())) fail(GetLastError()); // Pending I/O owns overlap until it completes, including cancellation. - struct CancelConnection { - HANDLE pipe; - OVERLAPPED* overlap; - bool pending; - ~CancelConnection() { - if (!pending) return; - CancelIoEx(pipe, overlap); + class CancelConnection final { + public: + CancelConnection(HANDLE pipe, OVERLAPPED& overlap, bool pending) noexcept + : pipe_(pipe), overlap_(overlap), pending_(pending) {} + ~CancelConnection() noexcept { + if (!pending_) return; + CancelIoEx(pipe_, &overlap_); DWORD ignored = 0; - GetOverlappedResult(pipe, overlap, &ignored, TRUE); + GetOverlappedResult(pipe_, &overlap_, &ignored, TRUE); } - } connection{listener.get(), &overlap, pending}; + CancelConnection(const CancelConnection&) = delete; + CancelConnection& operator=(const CancelConnection&) = delete; + CancelConnection(CancelConnection&&) = delete; + CancelConnection& operator=(CancelConnection&&) = delete; + void complete() noexcept { pending_ = false; } + private: + HANDLE pipe_; + OVERLAPPED& overlap_; + bool pending_; + } connection{listener.get(), overlap, pending}; const fs::path executable = executable_path(); std::wstring parameters; command_line::append_quoted(parameters, helper_switch); @@ -511,14 +573,14 @@ class Broker final { } UniqueHandle helper(execute.hProcess); const std::array waits{connected.get(), helper.get()}; - const DWORD waited = WaitForMultipleObjects(static_cast(waits.size()), waits.data(), FALSE, 60000); + const DWORD waited = WaitForMultipleObjects(static_cast(waits.size()), waits.data(), FALSE, pipe_timeout); if (waited != WAIT_OBJECT_0) { CancelIoEx(listener.get(), &overlap); fail(waited == WAIT_TIMEOUT ? ERROR_TIMEOUT : ERROR_BROKEN_PIPE); } DWORD transferred = 0; if (pending && !GetOverlappedResult(listener.get(), &overlap, &transferred, FALSE)) fail(GetLastError()); - connection.pending = false; + connection.complete(); ULONG client = 0; if (!GetNamedPipeClientProcessId(listener.get(), &client) || client != GetProcessId(helper.get())) { fail(ERROR_ACCESS_DENIED); @@ -587,6 +649,9 @@ int helper(const DWORD parent_pid, const std::wstring& pipe_name) { check_owner(opened.get(), user_sid(user)); // Rename the validated source handle. Reopening by pathname // would lose the private-journal access granted by backup semantics. + const std::lock_guard privilege_lock(privilege_mutex()); + const ScopedPrivilege backup(SE_BACKUP_NAME); + const ScopedPrivilege restore(SE_RESTORE_NAME); rename_without_replacement(opened.get(), second); opened.reset(); } else { @@ -721,8 +786,8 @@ std::optional dispatch_helper(const std::vector& arguments) { } } -void accept_elevated_launch(std::vector& arguments) { - if (arguments.empty() || arguments.front() != elevated_launch_switch) return; +std::optional accept_elevated_launch(std::vector& arguments) { + if (arguments.empty() || arguments.front() != elevated_launch_switch) return std::nullopt; if (arguments.size() < 4 || !is_elevated()) fail(ERROR_INVALID_PARAMETER); size_t consumed = 0; const unsigned long long value = std::stoull(arguments[1], &consumed); @@ -735,15 +800,11 @@ void accept_elevated_launch(std::vector& arguments) { if (!regular(steam_directory / L"steam.exe")) fail(ERROR_FILE_NOT_FOUND); // Steam's registry and environment may belong to the original account. // Only this authenticated private handoff can supply a discovery override. - accepted_steam_directory = steam_directory; // The complete forwarded command is independently validated by normal entry. if (!SetEnvironmentVariableW(L"SteamAppId", L"1085660") || !SetEnvironmentVariableW(L"SteamGameId", L"1085660")) fail(GetLastError()); arguments.erase(arguments.begin(), arguments.begin() + 3); -} - -std::optional inherited_steam_directory() { - return accepted_steam_directory; + return steam_directory; } std::optional relaunch_as_administrator(const std::vector& arguments, @@ -769,7 +830,7 @@ std::optional relaunch_as_administrator(const std::vector& ar if (waited != WAIT_OBJECT_0) fail(GetLastError()); DWORD status = 0; if (!GetExitCodeProcess(child.get(), &status)) return std::nullopt; - return static_cast(status); + return windows_exit_status(status); } bool launch_as_desktop_user(const fs::path& executable, const std::wstring& command_line, @@ -799,9 +860,10 @@ bool launch_as_desktop_user(const fs::path& executable, const std::wstring& comm if (!DuplicateTokenEx(token.get(), TOKEN_ALL_ACCESS, nullptr, SecurityImpersonation, TokenPrimary, &primary_raw)) return false; const UniqueHandle primary(primary_raw); + std::wstring desktop = L"winsta0\\default"; STARTUPINFOW startup{}; startup.cb = sizeof(startup); - startup.lpDesktop = const_cast(L"winsta0\\default"); + startup.lpDesktop = desktop.data(); // Both Windows process APIs may modify their command-line buffer even on // failure. Keep the caller's original available for its fallback launch. std::wstring mutable_command_line = command_line; diff --git a/src/logging.cpp b/src/logging.cpp index 6f064ac..62a5eb4 100644 --- a/src/logging.cpp +++ b/src/logging.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -39,7 +40,6 @@ void Logger::write(const std::string_view severity, const std::string_view messa try { SYSTEMTIME now{}; GetSystemTime(&now); - std::lock_guard lock(mutex_); std::ostringstream output; size_t start = 0; do { @@ -56,15 +56,18 @@ void Logger::write(const std::string_view severity, const std::string_view messa start = end + 1; } while (start <= message.size()); const std::string data = output.str(); - size_t offset = 0; - while (offset < data.size()) { - const DWORD requested = static_cast(std::min(data.size() - offset, MAXDWORD)); + auto remaining = std::as_bytes(std::span{data}); + // Formatting can allocate; only serialize the writes to this file. + std::lock_guard lock(mutex_); + while (!remaining.empty()) { + const DWORD requested = static_cast(std::min(remaining.size(), MAXDWORD)); DWORD written = 0; - if (!WriteFile(file_.get(), data.data() + offset, requested, &written, nullptr) || written == 0) { + if (!WriteFile(file_.get(), remaining.data(), requested, &written, nullptr) || written == 0 || + written > requested) { OutputDebugStringW(L"D2SteamFix: writing steamfix.log failed.\n"); return; } - offset += written; + remaining = remaining.subspan(written); } } catch (...) { OutputDebugStringW(L"D2SteamFix: writing steamfix.log failed.\n"); diff --git a/src/overlay.cpp b/src/overlay.cpp index cabe1c4..4fc4ed9 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -4,21 +4,22 @@ #include #include +#include namespace steamfix::overlay { namespace { -constexpr unsigned max_snapshot_attempts = 8; +constexpr int max_snapshot_attempts = 8; UniqueHandle module_snapshot() { DWORD error = ERROR_SUCCESS; - for (unsigned attempt = 0; attempt < max_snapshot_attempts; ++attempt) { - HANDLE raw = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, + for (int attempt = 0; attempt < max_snapshot_attempts; ++attempt) { + const HANDLE raw = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, GetCurrentProcessId()); if (raw != INVALID_HANDLE_VALUE) return UniqueHandle(raw); error = GetLastError(); if (error != ERROR_BAD_LENGTH) break; - if (attempt + 1U < max_snapshot_attempts) Sleep(1); + if (attempt + 1 < max_snapshot_attempts) Sleep(1); } throw std::system_error(static_cast(error), std::system_category()); } @@ -26,7 +27,7 @@ UniqueHandle module_snapshot() { } // namespace std::optional loaded_renderer() { - UniqueHandle snapshot = module_snapshot(); + const UniqueHandle snapshot = module_snapshot(); MODULEENTRY32W entry{}; entry.dwSize = sizeof(entry); if (!Module32FirstW(snapshot.get(), &entry)) { diff --git a/src/paths.cpp b/src/paths.cpp index 5ecdf94..4128c80 100644 --- a/src/paths.cpp +++ b/src/paths.cpp @@ -2,7 +2,9 @@ #include -#include +#include +#include +#include #include namespace fs = std::filesystem; @@ -15,14 +17,14 @@ bool equal_windows(const fs::path& left, const fs::path& right) { const std::wstring& a = left.native(); const std::wstring& b = right.native(); - if (a.size() > static_cast(INT_MAX) || b.size() > static_cast(INT_MAX)) return false; + if (a.size() > static_cast(INT_MAX) || b.size() > static_cast(INT_MAX)) return false; return CompareStringOrdinal(a.data(), static_cast(a.size()), b.data(), static_cast(b.size()), TRUE) == CSTR_EQUAL; } bool name_is(const fs::path& path, const std::wstring_view expected) { const std::wstring name = path.filename().native(); - if (name.size() != expected.size() || name.size() > static_cast(INT_MAX)) return false; + if (name.size() != expected.size() || name.size() > static_cast(INT_MAX)) return false; return CompareStringOrdinal(name.data(), static_cast(name.size()), expected.data(), static_cast(expected.size()), TRUE) == CSTR_EQUAL; } diff --git a/src/process.cpp b/src/process.cpp index c79ff4c..008b1ff 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -8,12 +8,17 @@ #include #include +#include +#include #include #include #include #include #include +#include +#include #include +#include namespace fs = std::filesystem; @@ -31,7 +36,7 @@ class ReleaseState { using Clock = std::chrono::steady_clock; static constexpr auto launcher_exit_grace = std::chrono::seconds(30); - static constexpr unsigned exit_debounce_polls = 2; + static constexpr int exit_debounce_polls = 2; [[nodiscard]] bool observe(const bool launcher_alive, const bool game_alive, const bool observation_reliable, @@ -56,7 +61,7 @@ class ReleaseState { return false; } } - if (absent_polls_ != std::numeric_limits::max()) ++absent_polls_; + if (absent_polls_ != std::numeric_limits::max()) ++absent_polls_; return absent_polls_ >= exit_debounce_polls; } @@ -67,12 +72,12 @@ class ReleaseState { } std::optional launcher_exit_observed_at_; - unsigned absent_polls_ = 0; + int absent_polls_ = 0; bool game_observed_ = false; }; -void increment_saturated(unsigned& value) noexcept { - if (value != std::numeric_limits::max()) ++value; +void increment_saturated(int& value) noexcept { + if (value != std::numeric_limits::max()) ++value; } std::uint64_t creation_key(const HANDLE process) { @@ -93,7 +98,7 @@ std::string process_message(const char* event, const std::wstring& name, const D } std::vector snapshot() { - UniqueHandle handle(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)); + const UniqueHandle handle(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)); PROCESSENTRY32W entry{}; entry.dwSize = sizeof(entry); if (!Process32FirstW(handle.get(), &entry)) { @@ -160,10 +165,10 @@ std::optional monitor(const HANDLE child, const DWORD child_pid, const fs:: std::map tracked; std::set previous; ReleaseState release; - unsigned failures = 0; + int failures = 0; bool candidate_uncertain = false; - unsigned candidate_uncertain_polls = 0; - unsigned child_wait_failures = 0; + int candidate_uncertain_polls = 0; + int child_wait_failures = 0; bool child_finished = false; std::optional child_status; @@ -191,7 +196,7 @@ std::optional monitor(const HANDLE child, const DWORD child_pid, const fs:: log.info("original launcher handle became queryable again"); child_wait_failures = 0; child_finished = true; - child_status = static_cast(status); + child_status = windows_exit_status(status); } else { // The signaled process handle proves the launcher exited. // UAC may provide a waitable handle without exit-code rights. @@ -222,7 +227,7 @@ std::optional monitor(const HANDLE child, const DWORD child_pid, const fs:: const bool game = process_name_is(item.name, L"destiny2.exe"); if (!launcher && !game) continue; try { - UniqueHandle handle = open_query(item.pid); + const UniqueHandle handle = open_query(item.pid); const fs::path path = image_path(handle.get()); if (!path.has_parent_path() || !paths::equal_windows(path.parent_path(), game_dir)) @@ -303,7 +308,7 @@ std::vector parent_chain() { parents[item.pid] = item.parent_pid; DWORD current = GetCurrentProcessId(); std::vector result; - for (unsigned depth = 0; depth < 8; ++depth) { + for (int depth = 0; depth < 8; ++depth) { const auto found = parents.find(current); if (found == parents.end() || found->second == 0 || found->second == current) break; result.push_back(found->second); diff --git a/src/repair.cpp b/src/repair.cpp index 833f486..a387061 100644 --- a/src/repair.cpp +++ b/src/repair.cpp @@ -19,10 +19,14 @@ #include #include #include +#include #include +#include #include +#include #include #include +#include #include #include @@ -44,17 +48,19 @@ bool missing(const DWORD error) noexcept { class MutexLock { public: - MutexLock(const wchar_t* name, const DWORD timeout) { - handle_ = UniqueHandle(CreateMutexW(nullptr, FALSE, name)); + MutexLock(const wchar_t* name, const DWORD timeout) + : handle_(CreateMutexW(nullptr, FALSE, name)) { const DWORD result = WaitForSingleObject(handle_.get(), timeout); if (result == WAIT_TIMEOUT) { throw std::runtime_error("another D2SteamFix session did not finish; close it and retry repair"); } if (result != WAIT_OBJECT_0 && result != WAIT_ABANDONED) throw_win32(GetLastError()); } - ~MutexLock() { ReleaseMutex(handle_.get()); } + ~MutexLock() noexcept { ReleaseMutex(handle_.get()); } MutexLock(const MutexLock&) = delete; MutexLock& operator=(const MutexLock&) = delete; + MutexLock(MutexLock&&) = delete; + MutexLock& operator=(MutexLock&&) = delete; private: UniqueHandle handle_; }; @@ -99,48 +105,71 @@ std::vector token_user(const HANDLE token) { if (!GetTokenInformation(token, TokenUser, result.data(), needed, &needed)) { throw_win32(GetLastError()); } + if (needed < sizeof(TOKEN_USER) || needed > result.size()) { + throw std::runtime_error("Windows returned an invalid token-user information size"); + } return result; } bool same_user(const HANDLE first, const HANDLE second) { const std::vector a = token_user(first); const std::vector b = token_user(second); - return EqualSid(reinterpret_cast(a.data())->User.Sid, - reinterpret_cast(b.data())->User.Sid) != FALSE; + // TokenUser is a variable-length Win32 record. Copy its trivially copyable + // header instead of treating byte storage as an aligned TOKEN_USER object. + static_assert(std::is_trivially_copyable_v); + TOKEN_USER first_user{}; + TOKEN_USER second_user{}; + std::memcpy(&first_user, a.data(), sizeof(first_user)); + std::memcpy(&second_user, b.data(), sizeof(second_user)); + return EqualSid(first_user.User.Sid, second_user.User.Sid) != FALSE; } bool access_allowed(const HANDLE file, const HANDLE token, const DWORD access) { + // -1 is also the current-process pseudo-handle. Security APIs can accept + // it, but this helper must only inspect an actual opened renderer file. + if (file == nullptr || file == INVALID_HANDLE_VALUE) throw_win32(ERROR_INVALID_HANDLE); PSECURITY_DESCRIPTOR descriptor = nullptr; const DWORD status = GetSecurityInfo(file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, nullptr, nullptr, nullptr, nullptr, &descriptor); if (status != ERROR_SUCCESS) throw_win32(status); - LocalMemory memory(descriptor); + const LocalMemory memory(descriptor); HANDLE raw = nullptr; if (!DuplicateToken(token, SecurityImpersonation, &raw)) throw_win32(GetLastError()); const UniqueHandle impersonation(raw); GENERIC_MAPPING mapping{FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_GENERIC_EXECUTE, FILE_ALL_ACCESS}; - std::vector privileges(sizeof(PRIVILEGE_SET) + 32U * sizeof(LUID_AND_ATTRIBUTES)); - DWORD length = static_cast(privileges.size()); + // AccessCheck uses a C record with a one-element trailing array. Keep the + // header typed and provide adjacent, aligned storage for its extra entries. + struct PrivilegeBuffer { + PRIVILEGE_SET header{}; + std::array additional{}; + }; + static_assert(std::is_standard_layout_v); + static_assert(offsetof(PrivilegeBuffer, additional) == sizeof(PRIVILEGE_SET)); + static_assert(sizeof(PrivilegeBuffer) == sizeof(PRIVILEGE_SET) + + 32 * sizeof(LUID_AND_ATTRIBUTES)); + PrivilegeBuffer privileges{}; + DWORD length = sizeof(privileges); DWORD granted = 0; BOOL allowed = FALSE; if (!AccessCheck(descriptor, impersonation.get(), access, &mapping, - reinterpret_cast(privileges.data()), &length, &granted, &allowed)) { + &privileges.header, &length, &granted, &allowed)) { throw_win32(GetLastError()); } return allowed != FALSE; } bool confirm() { - const TASKDIALOG_BUTTON buttons[]{{IDOK, L"Continue"}, {IDCANCEL, L"Cancel"}}; + constexpr std::array buttons{{ + {IDOK, L"Continue"}, {IDCANCEL, L"Cancel"}}}; TASKDIALOGCONFIG dialog{}; dialog.cbSize = sizeof(dialog); dialog.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION | TDF_POSITION_RELATIVE_TO_WINDOW; dialog.pszWindowTitle = L"D2SteamFix"; dialog.pszMainIcon = TD_INFORMATION_ICON; dialog.pszMainInstruction = L"Steam and Destiny 2 will close so Steam can repair its files."; - dialog.cButtons = static_cast(std::size(buttons)); - dialog.pButtons = buttons; + dialog.cButtons = static_cast(buttons.size()); + dialog.pButtons = buttons.data(); dialog.nDefaultButton = IDCANCEL; int selected = IDCANCEL; const HRESULT result = TaskDialogIndirect(&dialog, &selected, nullptr, nullptr); @@ -208,7 +237,7 @@ bool inside_steam(const fs::path& image, const fs::path& directory) { // Steam's client helpers live in child directories, never in steamapps. // Updating Steam may already have renamed or replaced a running image. fs::path parent = fs::weakly_canonical(image.parent_path()); - for (unsigned depth = 0; depth < 8; ++depth) { + for (int depth = 0; depth < 8; ++depth) { if (paths::equal_windows(parent, directory)) return true; if (paths::name_is(parent, L"steamapps")) return false; const fs::path next = parent.parent_path(); @@ -234,7 +263,7 @@ bool steam_process(const fs::path& image, const fs::path& selected_directory) { // Close Steam clients from other installations too, as repair promises to // close Steam before replacing files. Verify the installation by its client. fs::path parent = fs::weakly_canonical(image.parent_path()); - for (unsigned depth = 0; depth < 8; ++depth) { + for (int depth = 0; depth < 8; ++depth) { if (paths::name_is(parent, L"steamapps")) return false; const DWORD attributes = GetFileAttributesW((parent / L"steam.exe").c_str()); if (attributes != INVALID_FILE_ATTRIBUTES && @@ -271,12 +300,22 @@ struct Termination { Candidate candidate; }; +DWORD wait_for_process(const HANDLE process, const DWORD timeout) { + const DWORD result = WaitForSingleObject(process, timeout); + if (result == WAIT_FAILED) throw_win32(GetLastError()); + if (result != WAIT_OBJECT_0 && result != WAIT_TIMEOUT) { + throw std::runtime_error("waiting for a repair process returned unexpected status " + + std::to_string(result)); + } + return result; +} + std::vector prepare_shutdown(const fs::path& steam_directory) { std::vector result; for (const Candidate& candidate : candidates()) { try { UniqueHandle handle = elevation::open_process_for_termination(candidate.pid); - if (WaitForSingleObject(handle.get(), 0) == WAIT_OBJECT_0) continue; + if (wait_for_process(handle.get(), 0) == WAIT_OBJECT_0) continue; const fs::path image = process::image_path(handle.get()); if (!paths::name_is(image, candidate.name)) continue; if (!destiny_name(candidate.name) && !steam_process(image, steam_directory)) continue; @@ -292,19 +331,18 @@ std::vector prepare_shutdown(const fs::path& steam_directory) { void shutdown(const fs::path& steam_directory, const Logger& log) { // Prepare every handle before terminating anything, so UAC cancellation is harmless. std::vector targets = prepare_shutdown(steam_directory); - for (unsigned pass = 0; pass < 8; ++pass) { + for (int pass = 0; pass < 8; ++pass) { // Stop the game before Steam, including processes created while Steam was closing. for (const bool destiny : {true, false}) { for (const Termination& target : targets) { if (destiny_name(target.candidate.name) != destiny) continue; - if (WaitForSingleObject(target.process.get(), 0) == WAIT_OBJECT_0) continue; + if (wait_for_process(target.process.get(), 0) == WAIT_OBJECT_0) continue; if (!TerminateProcess(target.process.get(), ERROR_CANCELLED)) { const DWORD error = GetLastError(); if (WaitForSingleObject(target.process.get(), 0) != WAIT_OBJECT_0) throw_win32(error); } - const DWORD waited = WaitForSingleObject(target.process.get(), 10000); + const DWORD waited = wait_for_process(target.process.get(), 10000); if (waited != WAIT_OBJECT_0) { - if (waited == WAIT_FAILED) throw_win32(GetLastError()); throw std::runtime_error("a Steam or Destiny process did not exit during repair"); } log.info("Steam repair closed " + narrow(target.candidate.name) + @@ -393,12 +431,12 @@ void wait_for_repair(const HANDLE child, const fs::path& directory, const Logger const auto deadline = Clock::now() + std::chrono::minutes(20); std::optional quiet_since; for (;;) { - const DWORD wait = WaitForSingleObject(child, 500); - if (wait != WAIT_TIMEOUT && wait != WAIT_OBJECT_0) throw_win32(GetLastError()); + const DWORD wait = wait_for_process(child, 500); if (wait == WAIT_OBJECT_0 && !steam_client_alive(directory)) { const auto now = Clock::now(); - if (!quiet_since) quiet_since = now; - if (now - *quiet_since >= std::chrono::seconds(3)) { + const auto quiet_start = quiet_since.value_or(now); + quiet_since = quiet_start; + if (now - quiet_start >= std::chrono::seconds(3)) { DWORD code = 0; if (!GetExitCodeProcess(child, &code)) throw_win32(GetLastError()); log.info("Steam repair processes exited; original bootstrap status=" + std::to_string(code)); diff --git a/src/steam.cpp b/src/steam.cpp index 0df4474..48a0233 100644 --- a/src/steam.cpp +++ b/src/steam.cpp @@ -6,11 +6,13 @@ #include -#include #include +#include #include #include +#include #include +#include #include #include @@ -21,10 +23,12 @@ namespace { class RegistryKey { public: - explicit RegistryKey(HKEY key) : key_(key) {} - ~RegistryKey() { if (key_ != nullptr) RegCloseKey(key_); } + explicit RegistryKey(HKEY key) noexcept : key_(key) {} + ~RegistryKey() noexcept { if (key_ != nullptr) RegCloseKey(key_); } RegistryKey(const RegistryKey&) = delete; RegistryKey& operator=(const RegistryKey&) = delete; + RegistryKey(RegistryKey&&) = delete; + RegistryKey& operator=(RegistryKey&&) = delete; [[nodiscard]] HKEY get() const noexcept { return key_; } private: HKEY key_; @@ -34,10 +38,10 @@ std::wstring registry_string(const wchar_t* name) { HKEY raw = nullptr; LSTATUS status = RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\Valve\\Steam", 0, KEY_READ, &raw); if (status != ERROR_SUCCESS) throw std::system_error(static_cast(status), std::system_category()); - RegistryKey key(raw); + const RegistryKey key(raw); constexpr DWORD allowed_types = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ; constexpr DWORD max_registry_bytes = 128U * 1024U; - for (unsigned attempt = 0; attempt < 4; ++attempt) { + for (int attempt = 0; attempt < 4; ++attempt) { DWORD type = 0; DWORD bytes = 0; status = RegGetValueW(key.get(), nullptr, name, allowed_types, &type, nullptr, &bytes); @@ -47,7 +51,7 @@ std::wstring registry_string(const wchar_t* name) { if (bytes == 0 || bytes > max_registry_bytes || bytes % sizeof(wchar_t) != 0) { throw std::runtime_error("Steam registry value has an invalid string size"); } - std::vector buffer(static_cast(bytes / sizeof(wchar_t)) + 1U, L'\0'); + std::vector buffer(static_cast(bytes / sizeof(wchar_t)) + 1U, L'\0'); status = RegGetValueW(key.get(), nullptr, name, allowed_types, &type, buffer.data(), &bytes); if (status == ERROR_MORE_DATA) continue; if (status != ERROR_SUCCESS) { @@ -59,13 +63,12 @@ std::wstring registry_string(const wchar_t* name) { if (bytes > buffer.size() * sizeof(wchar_t)) { throw std::runtime_error("Steam registry value returned an inconsistent string size"); } - size_t units = bytes / sizeof(wchar_t); - while (units != 0 && buffer[units - 1] == L'\0') --units; - if (units == 0 || std::find(buffer.begin(), buffer.begin() + static_cast(units), - L'\0') != buffer.begin() + static_cast(units)) { + std::wstring_view value(buffer.data(), bytes / sizeof(wchar_t)); + while (!value.empty() && value.back() == L'\0') value.remove_suffix(1); + if (value.empty() || value.find(L'\0') != std::wstring_view::npos) { throw std::runtime_error("Steam registry value is empty or contains an embedded NUL"); } - return std::wstring(buffer.data(), units); + return std::wstring(value); } throw std::runtime_error("Steam registry value changed repeatedly while it was being read"); } diff --git a/tests/acl_tests.cpp b/tests/acl_tests.cpp new file mode 100644 index 0000000..17c385c --- /dev/null +++ b/tests/acl_tests.cpp @@ -0,0 +1,879 @@ +// Load production dependencies before redirecting the three selected Win32 +// call sites in acl.cpp. Declarations and separately compiled modules stay real. +#include "steamfix/acl.hpp" +#include "steamfix/error.hpp" +#include "steamfix/elevation.hpp" +#include "steamfix/logging.hpp" +#include "steamfix/paths.hpp" +#include "steamfix/win32.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace acl_fault { + +struct Plan { + int write_failures_remaining = 0; + int flush_failures_remaining = 0; + std::array security_failure_calls{}; + int write_failures = 0; + int flush_failures = 0; + int security_calls = 0; + int security_failures = 0; +}; + +// Each plan is borrowed only by the current test thread's bounded Scope. +thread_local Plan* active_plan = nullptr; + +BOOL WINAPI write_file(const HANDLE file, const LPCVOID bytes, const DWORD length, + const LPDWORD written, const LPOVERLAPPED overlap) { + if (active_plan != nullptr && active_plan->write_failures_remaining != 0) { + --active_plan->write_failures_remaining; + ++active_plan->write_failures; + if (written != nullptr) *written = 0; + SetLastError(ERROR_WRITE_FAULT); + return FALSE; + } + return ::WriteFile(file, bytes, length, written, overlap); +} + +BOOL WINAPI flush_file(const HANDLE file) { + if (active_plan != nullptr && active_plan->flush_failures_remaining != 0) { + --active_plan->flush_failures_remaining; + ++active_plan->flush_failures; + SetLastError(ERROR_WRITE_FAULT); + return FALSE; + } + return ::FlushFileBuffers(file); +} + +DWORD WINAPI set_security(const HANDLE handle, const SE_OBJECT_TYPE type, + const SECURITY_INFORMATION information, const PSID owner, + const PSID group, const PACL dacl, const PACL sacl) { + if (active_plan != nullptr) { + ++active_plan->security_calls; + for (const int failure_call : active_plan->security_failure_calls) { + if (active_plan->security_calls == failure_call) { + ++active_plan->security_failures; + return ERROR_WRITE_FAULT; + } + } + } + return ::SetSecurityInfo(handle, type, information, owner, group, dacl, sacl); +} + +class Scope final { +public: + explicit Scope(Plan& plan) noexcept : previous_(active_plan) { active_plan = &plan; } + ~Scope() noexcept { active_plan = previous_; } + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + Scope(Scope&&) = delete; + Scope& operator=(Scope&&) = delete; +private: + Plan* previous_; +}; + +} // namespace acl_fault + +// Keep actual binary readers and ACL/recovery state transitions in the test TU. +// With no active plan, every call below invokes the corresponding real API. +#define WriteFile acl_fault::write_file +#define FlushFileBuffers acl_fault::flush_file +#define SetSecurityInfo acl_fault::set_security +#include "../src/acl.cpp" +#undef WriteFile +#undef FlushFileBuffers +#undef SetSecurityInfo + +namespace { +namespace acl = steamfix::acl; +using steamfix::UniqueHandle; + +static_assert(!std::is_copy_constructible_v && + !std::is_copy_assignable_v && + !std::is_move_constructible_v && + !std::is_move_assignable_v && + std::is_nothrow_destructible_v); +static_assert(!std::is_copy_constructible_v && + !std::is_copy_assignable_v && + !std::is_move_constructible_v && + !std::is_move_assignable_v); + +std::size_t assertions = 0; + +void require(const bool condition, const std::string_view message) { + ++assertions; + if (!condition) throw std::runtime_error(std::string(message)); +} + +template + requires std::derived_from && std::invocable +void rejects(Action action, const std::string_view message) { + ++assertions; + try { + action(); + } catch (const Exception&) { + return; + } + throw std::runtime_error(std::string(message)); +} + +void native_require(const BOOL result, const char* message) { + if (!result) throw std::system_error(steamfix::windows_exit_status(GetLastError()), + std::system_category(), message); +} + +class TempDirectory final { +public: + TempDirectory() { + const fs::path parent = fs::temp_directory_path(); + for (unsigned attempt = 0; attempt != 100; ++attempt) { + path_ = parent / (L"D2SteamFix-acl-tests-" + std::to_wstring(GetCurrentProcessId()) + + L"-" + std::to_wstring(GetTickCount64()) + L"-" + + std::to_wstring(attempt)); + if (CreateDirectoryW(path_.c_str(), nullptr)) return; + if (GetLastError() != ERROR_ALREADY_EXISTS) native_require(FALSE, "create test directory"); + } + throw std::runtime_error("could not allocate a unique test directory"); + } + ~TempDirectory() noexcept { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + TempDirectory(const TempDirectory&) = delete; + TempDirectory& operator=(const TempDirectory&) = delete; + TempDirectory(TempDirectory&&) = delete; + TempDirectory& operator=(TempDirectory&&) = delete; + + const fs::path& path() const noexcept { return path_; } + +private: + fs::path path_; +}; + +void create_file(const fs::path& path, const std::span contents = {}) { + acl::UserIdentity user = acl::current_user(); + acl::RestrictedSecurity security(user.sid(), 0); + const UniqueHandle file(CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | + FILE_SHARE_WRITE | FILE_SHARE_DELETE, security.attributes(), + CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr)); + acl::write_all(file.get(), contents); +} + +std::vector file_bytes(const fs::path& path) { + std::ifstream file(path, std::ios::binary); + require(file.is_open(), "could not open test fixture for comparison"); + const std::vector contents{std::istreambuf_iterator{file}, + std::istreambuf_iterator{}}; + require(!file.bad(), "could not read test fixture for comparison"); + const auto bytes = std::as_bytes(std::span(contents)); + return {bytes.begin(), bytes.end()}; +} + +struct Fixture { + TempDirectory directory; + fs::path renderer = directory.path() / L"GameOverlayRenderer64.dll"; + fs::path renderer32 = directory.path() / L"GameOverlayRenderer.dll"; + fs::path state = acl::recovery_path(directory.path()); + steamfix::Logger log{directory.path() / L"steamfix.log"}; + + Fixture() { + create_file(directory.path() / L"steam.exe"); + create_file(directory.path() / L"destiny2.exe"); + create_file(directory.path() / L"destiny2launcher.exe"); + constexpr std::array contents{std::byte{0x4d}, std::byte{0x5a}, std::byte{0x00}}; + create_file(renderer, contents); + create_file(renderer32, contents); + } +}; + +std::vector binary(const std::initializer_list values) { + std::vector result; + result.reserve(values.size()); + for (const auto value : values) result.push_back(std::byte{value}); + return result; +} + +template + requires std::is_trivially_copyable_v +void replace_object(std::vector& bytes, const std::size_t offset, const T& value) { + require(offset <= bytes.size() && sizeof(T) <= bytes.size() - offset, + "test mutation exceeded its fixture"); + std::memcpy(std::span(bytes).subspan(offset, sizeof(T)).data(), &value, sizeof(T)); +} + +template + requires std::invocable +std::vector changed_descriptor(const std::vector& original, Action action) { + auto changed = original; + auto header = acl::object_at(changed, 0, "test descriptor"); + action(header); + replace_object(changed, 0, header); + return changed; +} + +std::vector relative_descriptor(PACL dacl, const bool present = true, + const bool protected_dacl = true, + PSID owner = nullptr, PACL sacl = nullptr) { + SECURITY_DESCRIPTOR descriptor{}; + native_require(InitializeSecurityDescriptor(&descriptor, SECURITY_DESCRIPTOR_REVISION), + "initialize descriptor fixture"); + native_require(SetSecurityDescriptorDacl(&descriptor, present ? TRUE : FALSE, dacl, FALSE), + "set descriptor DACL fixture"); + if (protected_dacl) { + native_require(SetSecurityDescriptorControl(&descriptor, SE_DACL_PROTECTED, + SE_DACL_PROTECTED), "protect descriptor fixture"); + } + if (owner != nullptr) { + native_require(SetSecurityDescriptorOwner(&descriptor, owner, FALSE), "set fixture owner"); + native_require(SetSecurityDescriptorGroup(&descriptor, owner, FALSE), "set fixture group"); + } + if (sacl != nullptr) { + native_require(SetSecurityDescriptorSacl(&descriptor, TRUE, sacl, FALSE), "set fixture SACL"); + } + DWORD size = 0; + static_cast(MakeSelfRelativeSD(&descriptor, nullptr, &size)); + require(GetLastError() == ERROR_INSUFFICIENT_BUFFER && size >= sizeof(SECURITY_DESCRIPTOR_RELATIVE), + "native descriptor sizing failed"); + std::vector result(size); + native_require(MakeSelfRelativeSD(&descriptor, result.data(), &size), "serialize descriptor fixture"); + return result; +} + +acl::OpenBackup prepared_backup(const fs::path& path) { + auto result = acl::make_backup(path); + auto user = acl::current_user(); + result.backup.denied_acl = acl::make_denied_acl(result.backup, user.sid()); + return result; +} + +void force_original(const acl::OpenBackup& opened) { + const auto original = acl::descriptor_parts(opened.backup.descriptor); + const DWORD error = SetSecurityInfo(opened.handle.get(), SE_FILE_OBJECT, original.information, + nullptr, nullptr, original.dacl, nullptr); + if (error != ERROR_SUCCESS) acl::throw_win32(error); +} + +void binary_and_utf16_tests() { + require(acl::checked_add(3, 4) == 7, "checked size addition changed a valid value"); + rejects([&] { static_cast(acl::checked_add(std::numeric_limits::max(), 1)); }, + "overflowed recovery offsets were accepted"); + const auto integers = binary({0x78, 0x56, 0x34, 0x12, 0x88, 0x77, 0x66, 0x55, + 0x44, 0x33, 0x22, 0x11}); + std::size_t offset = 0; + require(acl::take_u32(integers, offset) == 0x12345678U && offset == 4, + "u32 reader lost little-endian ordering or offset"); + require(acl::take_u64(integers, offset) == 0x1122334455667788ULL && offset == integers.size(), + "u64 reader lost little-endian ordering or offset"); + for (std::size_t length = 0; length < 4; ++length) { + offset = 0; + rejects([&] { static_cast(acl::take_u32(std::span(integers).first(length), offset)); }, + "truncated integer was accepted"); + } + offset = integers.size() - 7; + rejects([&] { static_cast(acl::take_u64(integers, offset)); }, "truncated u64 was accepted"); + std::vector encoded; + acl::put_u32(encoded, 0x12345678U); + acl::put_u64(encoded, 0x1122334455667788ULL); + require(encoded == integers, "integer writer changed the recovery format"); + rejects([&] { acl::put_size(encoded, std::numeric_limits::max() + size_t{1}); }, + "oversized recovery length was narrowed"); + rejects([&] { static_cast(acl::object_at(integers, integers.size(), "truncated")); }, + "object reader accepted an empty tail"); + + acl::validate_utf16(L"renderer \U0001f3ae"); + acl::validate_utf16(L""); + const std::array invalid_text{ + std::wstring(1, wchar_t{0}), std::wstring(1, wchar_t{0xd800}), + std::wstring(1, wchar_t{0xdc00}), std::wstring{wchar_t{0xd800}, L'x'}}; + for (const auto& text : invalid_text) { + rejects([&] { acl::validate_utf16(text); }, "invalid UTF-16 recovery path was accepted"); + } + rejects([&] { acl::validate_target_name(L"GameOverlayRenderer64.dll"); }, + "relative renderer path was accepted"); + rejects([&] { acl::validate_target_name(fs::temp_directory_path() / L"unrelated.dll"); }, + "unrelated recovery target was accepted"); + require(acl::missing_error(ERROR_FILE_NOT_FOUND) && acl::missing_error(ERROR_PATH_NOT_FOUND) && + !acl::missing_error(ERROR_ACCESS_DENIED), "missing-file errors were misclassified"); + rejects([&] { + static_cast(acl::protection_error("cancelled test", steamfix::elevation::Cancelled{})); + }, "cancellation was hidden by protection error wrapping"); + const auto wrapped = acl::protection_error("test stage", std::runtime_error("test detail")); + require(wrapped.code() == steamfix::ExitCode::overlay_protection && wrapped.stage() == "test stage" && + wrapped.detail() == "test detail", "protection errors lost their stage or detail"); +} + +void acl_and_descriptor_tests() { + // A complete deny ACE with one SID subauthority, independent of the native writer. + const auto valid = binary({2,0,28,0,1,0,0,0, 1,0,20,0,32,0,0,0, + 1,1,0,0,0,0,0,5,21,0,0,0}); + acl::validate_acl(valid); + require(IsValidAcl(acl::acl_from_bytes(valid)) != FALSE, "valid ACL fixture was rejected"); + for (std::size_t length = 0; length < valid.size(); ++length) { + rejects([&] { acl::validate_acl(std::span(valid).first(length)); }, + "truncated ACL fixture was accepted"); + } + const std::array corruptions{ + std::pair{std::size_t{0}, std::byte{0}}, // unsupported revision + std::pair{std::size_t{2}, std::byte{4}}, // ACL smaller than its header + std::pair{std::size_t{4}, std::byte{2}}, // missing second ACE + std::pair{std::size_t{10}, std::byte{0}}, // empty ACE + std::pair{std::size_t{10}, std::byte{19}}, // unaligned ACE + std::pair{std::size_t{10}, std::byte{24}}, // ACE beyond its ACL + std::pair{std::size_t{16}, std::byte{2}}, // invalid SID revision + std::pair{std::size_t{17}, std::byte{2}}, // truncated SID tail + std::pair{std::size_t{17}, std::byte{255}}}; + for (const auto& [position, value] : corruptions) { + auto changed = valid; + changed[position] = value; + rejects([&] { acl::validate_acl(changed); }, "malformed ACL fixture was accepted"); + } + const auto header_only = binary({2,0,12,0,1,0,0,0, 1,0,4,0}); + const auto mask_only = binary({2,0,16,0,1,0,0,0, 1,0,8,0,32,0,0,0}); + rejects([&] { acl::validate_acl(header_only); }, "deny ACE without its fields was accepted"); + rejects([&] { acl::validate_acl(mask_only); }, "deny ACE without a SID was accepted"); + std::vector short_acl_in_larger_descriptor(sizeof(SECURITY_DESCRIPTOR_RELATIVE)); + short_acl_in_larger_descriptor.insert(short_acl_in_larger_descriptor.end(), valid.begin(), valid.end()); + replace_object(short_acl_in_larger_descriptor, sizeof(SECURITY_DESCRIPTOR_RELATIVE) + 2U, WORD{12}); + rejects([&] { + acl::validate_relative_acl(short_acl_in_larger_descriptor, sizeof(SECURITY_DESCRIPTOR_RELATIVE)); + }, "ACE escaped its ACL into the surrounding descriptor"); + auto extra = valid; + extra.push_back(std::byte{0}); + rejects([&] { static_cast(acl::acl_from_bytes(extra)); }, "ACL trailing bytes were accepted"); + const std::vector oversized_acl(size_t{MAXWORD} + 1U); + rejects([&] { static_cast(acl::acl_from_bytes(oversized_acl)); }, "oversized ACL was accepted"); + + auto user = acl::current_user(); + auto copied_user = user; + require(EqualSid(user.sid(), copied_user.sid()) != FALSE && user.sid() != copied_user.sid(), + "copied user identity retained a pointer into its source"); + acl::RestrictedSecurity security(user.sid(), 0); + const auto descriptor = relative_descriptor(security.acl(), true, true, user.sid(), security.acl()); + acl::validate_relative_descriptor(descriptor); + const auto parts = acl::descriptor_parts(descriptor); + require(parts.dacl != nullptr && (parts.information & PROTECTED_DACL_SECURITY_INFORMATION) != 0, + "protected descriptor inheritance was lost"); + const auto unprotected = relative_descriptor(security.acl(), true, false); + require((acl::descriptor_parts(unprotected).information & UNPROTECTED_DACL_SECURITY_INFORMATION) != 0, + "unprotected descriptor inheritance was lost"); + const auto null_dacl = relative_descriptor(nullptr); + require(acl::descriptor_parts(null_dacl).dacl == nullptr, "null DACL was changed into an empty DACL"); + const auto absent_dacl = relative_descriptor(nullptr, false); + rejects([&] { static_cast(acl::descriptor_parts(absent_dacl)); }, "absent DACL was accepted"); + const std::array descriptor_changes{ + changed_descriptor(descriptor, [](auto& h) { h.Revision = 0; }), + changed_descriptor(descriptor, [](auto& h) { + h.Control = static_cast(h.Control & ~SE_SELF_RELATIVE); + }), + changed_descriptor(descriptor, [](auto& h) { h.Owner = 4; }), + changed_descriptor(descriptor, [](auto& h) { h.Group = 21; }), + changed_descriptor(descriptor, [](auto& h) { h.Dacl = MAXDWORD - 3U; }), + changed_descriptor(descriptor, [](auto& h) { + h.Control = static_cast(h.Control & ~SE_DACL_PRESENT); + }), + changed_descriptor(descriptor, [](auto& h) { + h.Control = static_cast(h.Control & ~SE_SACL_PRESENT); + })}; + for (const auto& changed : descriptor_changes) { + rejects([&] { acl::validate_relative_descriptor(changed); }, "malformed descriptor was accepted"); + } + auto trailing = descriptor; + trailing.push_back(std::byte{0}); + rejects([&] { acl::validate_relative_descriptor(trailing); }, "descriptor trailing bytes were accepted"); + rejects([&] { acl::validate_relative_descriptor(std::span(descriptor).first(8)); }, + "truncated descriptor header was accepted"); + rejects([&] { acl::validate_relative_acl(descriptor, 21); }, "unaligned ACL offset was accepted"); + rejects([&] { acl::validate_relative_sid(descriptor, MAXDWORD - 3U); }, + "SID offset outside descriptor was accepted"); + require(acl::acl_equal(nullptr, nullptr) && !acl::acl_equal(nullptr, security.acl()) && + acl::explicit_acl_equal(nullptr, nullptr) && !acl::explicit_acl_equal(nullptr, security.acl()), + "null ACL equality is inconsistent"); + require(acl::acl_equal(security.acl(), security.acl()) && + acl::explicit_acl_equal(security.acl(), security.acl()), "identical ACLs compared unequal"); + require(!acl::dacl_state_matches(security.acl(), 0, parts), "DACL protection flag was ignored"); + + auto changed_mask = valid; + changed_mask[12] ^= std::byte{1}; + require(!acl::acl_equal(acl::acl_from_bytes(valid), acl::acl_from_bytes(changed_mask)) && + !acl::explicit_acl_equal(acl::acl_from_bytes(valid), acl::acl_from_bytes(changed_mask)), + "different ACE access masks compared equal"); + require(!acl::acl_equal(security.acl(), acl::acl_from_bytes(valid)), "different ACE counts compared equal"); + auto revision4 = valid; + revision4[0] = std::byte{ACL_REVISION_DS}; + require(!acl::acl_equal(acl::acl_from_bytes(valid), acl::acl_from_bytes(revision4)), + "different ACL revisions compared equal"); + auto inherited = valid; + inherited[9] = std::byte{INHERITED_ACE}; + const auto empty = binary({2,0,8,0,0,0,0,0}); + require(acl::explicit_acl_equal(acl::acl_from_bytes(inherited), acl::acl_from_bytes(empty)) && + acl::explicit_acl_equal(acl::acl_from_bytes(empty), acl::acl_from_bytes(inherited)), + "inherited ACEs were compared as explicit permissions"); + require(!acl::explicit_acl_equal(acl::acl_from_bytes(valid), acl::acl_from_bytes(empty)), + "an explicit ACE compared equal to an empty ACL"); + auto allowed = valid; + allowed[8] = std::byte{ACCESS_ALLOWED_ACE_TYPE}; + acl::validate_acl(allowed); + acl::Backup originally_unrestricted{fs::temp_directory_path() / L"GameOverlayRenderer64.dll", null_dacl, {}}; + originally_unrestricted.denied_acl = acl::make_denied_acl(originally_unrestricted, user.sid()); + require(!acl::has_current_user_execute_deny(originally_unrestricted, user.sid()), + "null DACL was reported as an existing execute denial"); + require(acl::acl_from_bytes(originally_unrestricted.denied_acl)->AceCount == 1, + "null DACL did not produce exactly one execute-only denial"); +} + +void serialization_tests() { + Fixture fixture; + auto opened = prepared_backup(fixture.renderer); + const std::array backups{opened.backup}; + const std::array identities{opened.identity}; + const auto plaintext = acl::encode_backups(backups, identities); + const auto decoded = acl::decode_payload(plaintext); + require(decoded.size() == 1 && decoded[0].identity == opened.identity && + decoded[0].backup.path == opened.backup.path && + decoded[0].backup.descriptor == opened.backup.descriptor && + decoded[0].backup.denied_acl == opened.backup.denied_acl, + "payload round trip changed a path, file identity, or saved ACL"); + const auto sealed = acl::protect_state(plaintext); + require(acl::unprotect_state(std::span(sealed).subspan(acl::state_magic.size())) == plaintext, + "DPAPI plaintext round trip changed the payload"); + require(acl::decode_backups(sealed)[0].backup.descriptor == opened.backup.descriptor, + "sealed backup round trip changed original permissions"); + auto damaged = sealed; + damaged.back() ^= std::byte{0x80}; + rejects([&] { static_cast(acl::decode_backups(damaged)); }, "corrupted DPAPI state was accepted"); + damaged = sealed; + damaged[0] ^= std::byte{1}; + rejects([&] { static_cast(acl::decode_backups(damaged)); }, "wrong state magic was accepted"); + rejects([&] { static_cast(acl::decode_backups(acl::state_magic)); }, "empty sealed state was accepted"); + rejects([&] { static_cast(acl::protect_state({})); }, "empty DPAPI plaintext was accepted"); + rejects([&] { static_cast(acl::unprotect_state({})); }, "empty DPAPI state was accepted"); + const std::vector excessive_plaintext(static_cast(acl::max_state) + 1U); + const auto excessive_sealed = acl::protect_state(excessive_plaintext); + rejects([&] { + static_cast(acl::unprotect_state(std::span(excessive_sealed).subspan(acl::state_magic.size()))); + }, "oversized decrypted recovery data was accepted"); + const std::vector excessive_ciphertext(static_cast(acl::max_state_file) + 1U); + rejects([&] { static_cast(acl::protect_state(excessive_ciphertext)); }, + "oversized encrypted recovery data was produced"); + for (std::size_t length = 0; length < plaintext.size(); ++length) { + rejects([&] { static_cast(acl::decode_payload(std::span(plaintext).first(length))); }, + "truncated recovery payload was accepted"); + } + const std::size_t units_position = acl::payload_magic.size() + sizeof(std::uint32_t); + const std::size_t descriptor_position = units_position + sizeof(std::uint32_t) + + opened.backup.path.native().size() * sizeof(std::uint16_t) + sizeof(std::uint32_t) + sizeof(std::uint64_t); + const std::size_t denied_position = descriptor_position + sizeof(std::uint32_t) + opened.backup.descriptor.size(); + const std::array field_corruptions{ + std::pair{acl::payload_magic.size(), std::uint32_t{0}}, + std::pair{acl::payload_magic.size(), std::uint32_t{3}}, + std::pair{units_position, std::uint32_t{0}}, + std::pair{units_position, std::uint32_t{32768}}, + std::pair{descriptor_position, std::uint32_t{0}}, + std::pair{descriptor_position, std::uint32_t{1048577}}, + std::pair{descriptor_position, std::uint32_t{1048576}}, + std::pair{denied_position, std::uint32_t{0}}, + std::pair{denied_position, std::uint32_t{65536}}, + std::pair{denied_position, std::uint32_t{65535}}}; + for (const auto& [position, value] : field_corruptions) { + auto changed = plaintext; + replace_object(changed, position, value); + rejects([&] { static_cast(acl::decode_payload(changed)); }, "invalid payload field was accepted"); + } + auto invalid_path = plaintext; + replace_object(invalid_path, units_position + sizeof(std::uint32_t), std::uint16_t{0}); + rejects([&] { static_cast(acl::decode_payload(invalid_path)); }, "embedded NUL path was accepted"); + auto trailing = plaintext; + trailing.push_back(std::byte{0}); + rejects([&] { static_cast(acl::decode_payload(trailing)); }, "payload trailing bytes were accepted"); + auto bad_magic = plaintext; + bad_magic[0] ^= std::byte{1}; + rejects([&] { static_cast(acl::decode_payload(bad_magic)); }, "wrong payload magic was accepted"); + rejects([&] { static_cast(acl::encode_backups(backups, {})); }, + "backup and identity count mismatch was accepted"); + rejects([&] { acl::restore_all(backups, {}); }, + "backup and handle count mismatch was accepted"); + auto invalid_backup = backups; + invalid_backup[0].denied_acl.clear(); + rejects([&] { static_cast(acl::encode_backups(invalid_backup, identities)); }, + "backup without its protected ACL was serialized"); + invalid_backup = backups; + invalid_backup[0].path = fs::path(std::wstring{L'C', L':', L'\\', wchar_t{0}, L'x'}); + rejects([&] { static_cast(acl::encode_backups(invalid_backup, identities)); }, + "backup containing an embedded NUL path was serialized"); + invalid_backup = backups; + invalid_backup[0].descriptor.resize(static_cast(acl::max_state) + 1U); + auto user = acl::current_user(); + rejects([&] { acl::write_state(fixture.state, invalid_backup, identities, user.sid()); }, + "oversized backup plaintext was persisted"); + require(!fs::exists(fixture.state), "oversized plaintext created a journal"); +} + +void protection_lifecycle_tests() { + Fixture fixture; + auto first = prepared_backup(fixture.renderer); + auto second = prepared_backup(fixture.renderer32); + auto user = acl::current_user(); + require(!acl::has_current_user_execute_deny(first.backup, user.sid()), "new fixture already denies execute"); + require(acl::restoration_state(first.backup, first.handle.get()) == acl::RestorationState::restored, + "new fixture is not in its original permission state"); + require(!acl::restore_one(first.backup, first.handle.get()), "restoring original permissions reported a change"); + require(acl::open_target(fixture.renderer, FILE_READ_ATTRIBUTES).identity == first.identity, + "independent opens disagree about file identity"); + acl::require_current_user_owner(first.handle.get(), user.sid()); + alignas(DWORD) std::array system{}; + DWORD system_length = static_cast(system.size()); + native_require(CreateWellKnownSid(WinLocalSystemSid, nullptr, system.data(), &system_length), "create SYSTEM SID"); + if (!EqualSid(user.sid(), system.data())) { + rejects([&] { acl::require_current_user_owner(first.handle.get(), system.data()); }, + "wrong owner was accepted"); + } + const std::array targets{fixture.renderer, fixture.renderer32}; + { + auto protection = acl::Protection::apply(targets, fixture.state, fixture.log); + require(fs::exists(fixture.state), "active protection has no recovery state"); + require(acl::read_state(fixture.state).size() == 2, "both renderer backups were not persisted"); + acl::verify_denied(first.backup, first.handle.get()); + acl::verify_denied(second.backup, second.handle.get()); + protection.restore(fixture.log); + protection.restore(fixture.log); + acl::verify_restored(first.backup, first.handle.get()); + acl::verify_restored(second.backup, second.handle.get()); + require(!fs::exists(fixture.state), "explicit restoration retained its journal"); + } + { + auto protection = acl::Protection::apply(std::span(targets).first(1), fixture.state, fixture.log); + acl::verify_protection(first.backup, first.handle.get()); + } + acl::verify_restored(first.backup, first.handle.get()); + require(!fs::exists(fixture.state), "destructor restoration retained its journal"); + acl::deny(first.backup, first.handle.get()); + rejects([&] { + static_cast(acl::Protection::apply(std::span(targets).first(1), fixture.state, fixture.log)); + }, "preexisting execute denial was backed up without a matching journal"); + require(!fs::exists(fixture.state), "preexisting denial created a misleading recovery backup"); + force_original(first); + rejects([&] { + static_cast(acl::Protection::apply({}, fixture.state, fixture.log)); + }, "empty protection target set was accepted"); + const std::array excessive_targets{fixture.renderer, fixture.renderer32, fixture.renderer}; + rejects([&] { + static_cast(acl::Protection::apply(excessive_targets, fixture.state, fixture.log)); + }, "more than two renderer targets were accepted"); + const std::array duplicates{fixture.renderer, fixture.renderer}; + rejects([&] { + static_cast(acl::Protection::apply(duplicates, fixture.state, fixture.log)); + }, "duplicate renderer targets were accepted"); + const std::array duplicate_alias{fixture.renderer, + fixture.renderer.parent_path() / L"." / fixture.renderer.filename()}; + rejects([&] { + static_cast(acl::Protection::apply(duplicate_alias, fixture.state, fixture.log)); + }, "two aliases of the same renderer were accepted"); + require(!fs::exists(fixture.state), "duplicate targets persisted an unusable journal"); + acl::verify_restored(first.backup, first.handle.get()); + acl::verify_restored(second.backup, second.handle.get()); + + // Failure for one target must still attempt restoration of the others. + acl::deny(second.backup, second.handle.get()); + const std::array saved{first.backup, second.backup}; + const std::array handles{ + UniqueHandle{}, acl::open_target(fixture.renderer32, READ_CONTROL | WRITE_DAC).handle}; + rejects([&] { acl::restore_all(saved, handles); }, "failed restoration handle was ignored"); + acl::verify_restored(second.backup, second.handle.get()); +} + +void pending_recovery_tests() { + Fixture fixture; + auto opened = prepared_backup(fixture.renderer); + auto user = acl::current_user(); + const std::array backups{opened.backup}; + const std::array identities{opened.identity}; + require(!acl::recover_pending(fixture.state, fixture.log), "missing journal reported recovered work"); + acl::write_state(fixture.state, backups, identities, user.sid()); + acl::deny(opened.backup, opened.handle.get()); + require(acl::recover_pending(fixture.state, fixture.log), "interrupted session was not recovered"); + acl::verify_restored(opened.backup, opened.handle.get()); + require(!fs::exists(fixture.state), "successful recovery kept the journal"); + + // Resume uses the persisted original ACL, including when protection is already active. + acl::write_state(fixture.state, backups, identities, user.sid()); + acl::deny(opened.backup, opened.handle.get()); + { + const std::array targets{fixture.renderer}; + auto resumed = acl::Protection::apply(targets, fixture.state, fixture.log); + require(acl::read_state(fixture.state)[0].backup.descriptor == opened.backup.descriptor, + "resuming replaced the original ACL backup"); + acl::verify_denied(opened.backup, opened.handle.get()); + resumed.restore(fixture.log); + } + acl::write_state(fixture.state, backups, identities, user.sid()); + require(acl::recover_pending(fixture.state, fixture.log), "already-restored pending journal was not cleaned"); + + acl::write_state(fixture.state, backups, identities, user.sid()); + { + const std::array targets{fixture.renderer}; + auto resumed = acl::Protection::apply(targets, fixture.state, fixture.log); + acl::verify_denied(opened.backup, opened.handle.get()); + resumed.restore(fixture.log); + } + + fs::path temporary = fixture.state; + temporary += L".tmp"; + create_file(temporary); + require(!acl::recover_pending(fixture.state, fixture.log) && !fs::exists(temporary), + "incomplete temporary journal was not removed"); + + auto wrong_identity = identities; + wrong_identity[0].file_index ^= 1U; + acl::write_state(fixture.state, backups, wrong_identity, user.sid()); + rejects([&] { static_cast(acl::recover_pending(fixture.state, fixture.log)); }, + "changed file identity was recovered"); + require(fs::exists(fixture.state), "identity mismatch discarded the original journal"); + native_require(DeleteFileW(fixture.state.c_str()), "remove mismatched test journal"); + + // An unrelated ACL change must preserve both that ACL and the original journal. + acl::write_state(fixture.state, backups, identities, user.sid()); + const auto saved_journal = file_bytes(fixture.state); + const DWORD status = SetSecurityInfo(opened.handle.get(), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, nullptr, nullptr); + if (status != ERROR_SUCCESS) acl::throw_win32(status); + rejects([&] { static_cast(acl::restoration_state(opened.backup, opened.handle.get())); }, + "external ACL change was mistaken for a saved state"); + rejects([&] { static_cast(acl::recover_pending(fixture.state, fixture.log)); }, + "external ACL change was overwritten by recovery"); + require(fs::exists(fixture.state), "external ACL change discarded the journal"); + const auto after_rejection = acl::current_descriptor(opened.handle.get()); + require(acl::descriptor_parts(after_rejection).dacl == nullptr, + "recovery changed the external DACL before reporting failure"); + require(file_bytes(fixture.state) == saved_journal, "failed recovery modified the saved journal"); + force_original(opened); + require(acl::recover_pending(fixture.state, fixture.log), "restored external change did not permit recovery"); + + acl::write_state(fixture.state, backups, identities, user.sid()); + const std::array other_target{fixture.renderer32}; + rejects([&] { + static_cast(acl::Protection::apply(other_target, fixture.state, fixture.log)); + }, "mismatched saved target set was accepted"); + require(fs::exists(fixture.state), "saved target mismatch discarded the journal"); + native_require(DeleteFileW(fixture.state.c_str()), "remove saved target test journal"); + + acl::write_state(fixture.state, backups, identities, user.sid()); + const std::array both_targets{fixture.renderer, fixture.renderer32}; + rejects([&] { + static_cast(acl::Protection::apply(both_targets, fixture.state, fixture.log)); + }, "saved and selected renderer count mismatch was accepted"); + native_require(DeleteFileW(fixture.state.c_str()), "remove saved count test journal"); + const std::array duplicate_backups{opened.backup, opened.backup}; + const std::array duplicate_identities{opened.identity, opened.identity}; + acl::write_state(fixture.state, duplicate_backups, duplicate_identities, user.sid()); + rejects([&] { + static_cast(acl::Protection::apply(both_targets, fixture.state, fixture.log)); + }, "duplicate persisted renderer paths were accepted"); + require(fs::exists(fixture.state), "invalid saved renderer set discarded the journal"); + native_require(DeleteFileW(fixture.state.c_str()), "remove duplicate saved test journal"); + + { + const std::array targets{fixture.renderer}; + auto protection = acl::Protection::apply(targets, fixture.state, fixture.log); + const DWORD changed = SetSecurityInfo(opened.handle.get(), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, nullptr, nullptr); + if (changed != ERROR_SUCCESS) acl::throw_win32(changed); + } + require(fs::exists(fixture.state), "failed destructor restoration discarded the journal"); + const auto external = acl::current_descriptor(opened.handle.get()); + require(acl::descriptor_parts(external).dacl == nullptr, "destructor overwrote an external permission change"); + force_original(opened); + require(acl::recover_pending(fixture.state, fixture.log), "preserved destructor-failure journal was unusable"); + + opened.handle.reset(); + native_require(DeleteFileW(fixture.renderer.c_str()), "remove test renderer"); + const acl::RecoveryEntry missing{opened.backup, opened.identity}; + require(!acl::prepare_recovery_entry(missing, fixture.log), "missing renderer passed recovery preflight"); + acl::write_state(fixture.state, backups, identities, user.sid()); + rejects([&] { static_cast(acl::recover_pending(fixture.state, fixture.log)); }, + "missing renderer discarded its pending recovery"); + require(fs::exists(fixture.state), "missing renderer discarded its journal"); +} + +void state_file_and_neighbor_tests() { + Fixture fixture; + auto opened = prepared_backup(fixture.renderer); + auto user = acl::current_user(); + const std::array backups{opened.backup}; + const std::array identities{opened.identity}; + const std::array bytes{std::byte{1}, std::byte{2}, std::byte{3}}; + create_file(fixture.state, bytes); + rejects([&] { static_cast(acl::read_state(fixture.state)); }, "invalid journal bytes were accepted"); + // A failed commit must close and remove its exclusively opened .tmp file. + rejects([&] { acl::write_state(fixture.state, backups, identities, user.sid()); }, + "journal writer replaced an existing backup"); + require(file_bytes(fixture.state) == std::vector(bytes.begin(), bytes.end()), + "failed journal commit modified the existing destination"); + fs::path temporary = fixture.state; + temporary += L".tmp"; + require(!fs::exists(temporary), "failed journal commit leaked a locked temporary file"); + native_require(DeleteFileW(fixture.state.c_str()), "remove invalid test journal"); + create_file(fixture.state); + rejects([&] { static_cast(acl::read_state(fixture.state)); }, "empty journal file was accepted"); + { + const UniqueHandle file(CreateFileW(fixture.state.c_str(), GENERIC_WRITE, 0, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + LARGE_INTEGER size{}; + size.QuadPart = static_cast(acl::max_state_file) + 1; + native_require(SetFilePointerEx(file.get(), size, nullptr, FILE_BEGIN), "size test journal"); + native_require(SetEndOfFile(file.get()), "extend test journal"); + } + rejects([&] { static_cast(acl::read_state(fixture.state)); }, "oversized journal file was accepted"); + native_require(DeleteFileW(fixture.state.c_str()), "remove oversized test journal"); + const fs::path steam = fixture.directory.path() / L"steam.exe"; + native_require(DeleteFileW(steam.c_str()), "remove test steam neighbor"); + require(!acl::has_regular_steam_neighbor(fixture.renderer), "missing Steam neighbor was accepted"); + rejects([&] { acl::require_regular_steam_neighbor(fixture.renderer); }, "missing Steam neighbor was required"); + native_require(CreateDirectoryW(steam.c_str(), nullptr), "create directory Steam neighbor"); + require(!acl::has_regular_steam_neighbor(fixture.renderer), "directory Steam neighbor was accepted"); + const acl::RecoveryEntry entry{opened.backup, opened.identity}; + require(!acl::prepare_recovery_entry(entry, fixture.log), "unsafe Steam neighbor passed preflight"); +} + +void journal_write_failure_tests() { + for (const bool fail_flush : {false, true}) { + Fixture fixture; + const auto first = prepared_backup(fixture.renderer); + const auto second = prepared_backup(fixture.renderer32); + const auto first_bytes = file_bytes(fixture.renderer); + const auto second_bytes = file_bytes(fixture.renderer32); + const std::array targets{fixture.renderer, fixture.renderer32}; + acl_fault::Plan plan; + if (fail_flush) plan.flush_failures_remaining = 1; + else plan.write_failures_remaining = 1; + { + const acl_fault::Scope faults(plan); + rejects([&] { + try { + static_cast(acl::Protection::apply(targets, fixture.state, fixture.log)); + } catch (const steamfix::AppError& error) { + require(error.stage() == "ACL recovery persistence", + "journal fault failed outside persistence"); + throw; + } + }, "journal write/flush fault did not stop protection"); + } + require(plan.write_failures == (fail_flush ? 0 : 1) && + plan.flush_failures == (fail_flush ? 1 : 0) && plan.security_calls == 0, + "journal fault was not consumed before all renderer permission changes"); + require(plan.write_failures_remaining == 0 && plan.flush_failures_remaining == 0, + "journal fault remained armed"); + fs::path temporary = fixture.state; + temporary += L".tmp"; + require(!fs::exists(fixture.state) && !fs::exists(temporary), + "failed journal I/O left an exclusively locked temporary file or committed journal"); + acl::verify_restored(first.backup, first.handle.get()); + acl::verify_restored(second.backup, second.handle.get()); + require(file_bytes(fixture.renderer) == first_bytes && file_bytes(fixture.renderer32) == second_bytes, + "failed journal I/O changed renderer contents"); + + // A successful retry also proves cleanup released the old .tmp name and handle. + auto retried = acl::Protection::apply(targets, fixture.state, fixture.log); + acl::verify_denied(first.backup, first.handle.get()); + acl::verify_denied(second.backup, second.handle.get()); + retried.restore(fixture.log); + acl::verify_restored(first.backup, first.handle.get()); + acl::verify_restored(second.backup, second.handle.get()); + require(!fs::exists(fixture.state) && !fs::exists(temporary), + "retry after failed journal I/O retained recovery files"); + } +} + +void partial_denial_rollback_tests() { + for (const bool fail_rollback : {false, true}) { + Fixture fixture; + const auto first = prepared_backup(fixture.renderer); + const auto second = prepared_backup(fixture.renderer32); + const auto first_bytes = file_bytes(fixture.renderer); + const auto second_bytes = file_bytes(fixture.renderer32); + const std::array targets{fixture.renderer, fixture.renderer32}; + acl_fault::Plan plan; + // In a fresh apply, calls 1 and 2 deny the first and second renderer. + // Call 3 restores the already-denied first renderer during rollback. + plan.security_failure_calls = {2, fail_rollback ? 3 : 0}; + { + const acl_fault::Scope faults(plan); + rejects([&] { + try { + static_cast(acl::Protection::apply(targets, fixture.state, fixture.log)); + } catch (const steamfix::AppError& error) { + require(error.stage() == "renderer permission denial", + "second denial fault failed outside denial rollback"); + throw; + } + }, "second renderer denial fault did not fail protection"); + } + require(plan.security_calls == 3 && plan.security_failures == (fail_rollback ? 2 : 1), + "second denial and first rollback did not reach the intended finite faults"); + acl::verify_restored(second.backup, second.handle.get()); + fs::path temporary = fixture.state; + temporary += L".tmp"; + require(!fs::exists(temporary), "denial rollback retained a temporary journal"); + if (fail_rollback) { + acl::verify_denied(first.backup, first.handle.get()); + require(fs::exists(fixture.state), "failed rollback discarded the original recovery journal"); + const auto saved = acl::read_state(fixture.state); + require(saved.size() == 2 && saved[0].backup.descriptor == first.backup.descriptor && + saved[1].backup.descriptor == second.backup.descriptor && + saved[0].identity == first.identity && saved[1].identity == second.identity, + "failed rollback did not preserve both original ACLs and identities"); + require(acl::recover_pending(fixture.state, fixture.log), + "real recovery could not finish the interrupted rollback"); + } else { + acl::verify_restored(first.backup, first.handle.get()); + require(!fs::exists(fixture.state), "successful denial rollback retained its journal"); + } + acl::verify_restored(first.backup, first.handle.get()); + acl::verify_restored(second.backup, second.handle.get()); + require(!fs::exists(fixture.state) && file_bytes(fixture.renderer) == first_bytes && + file_bytes(fixture.renderer32) == second_bytes, + "rollback/recovery left a journal or changed renderer contents"); + } +} + +} // namespace + +int main() { + try { + binary_and_utf16_tests(); + acl_and_descriptor_tests(); + serialization_tests(); + protection_lifecycle_tests(); + pending_recovery_tests(); + state_file_and_neighbor_tests(); + journal_write_failure_tests(); + partial_denial_rollback_tests(); + std::cout << "ACL tests passed: " << assertions << " assertions using real Win32 APIs.\n"; + } catch (const std::exception& error) { + std::cerr << "ACL regression test failed after " << assertions << " assertions: " + << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/tests/app_tests.cpp b/tests/app_tests.cpp new file mode 100644 index 0000000..80c34d6 --- /dev/null +++ b/tests/app_tests.cpp @@ -0,0 +1,733 @@ +// Include the implementation to exercise private validation without exposing a +// test-only application API. Link the remaining production modules normally. +#include "steamfix/elevation.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace app_fault { + +enum class Kind { process_error, cancelled, relaunch, standard_error, unexpected_error, real_launch }; +enum class Retry { status, unknown_status, cancelled, failure }; + +struct Plan { + Kind kind = Kind::process_error; + DWORD error = ERROR_BAD_EXE_FORMAT; + bool elevated = false; + std::filesystem::path steam; + int cleanup_failures = 0; + std::wstring entry_command{}; + std::filesystem::path launcher{}; + Retry retry = Retry::status; + bool parse_failure = false; + bool executable_failure = false; + bool existing_session = false; + bool unexpected_startup_failure = false; +}; + +struct UnexpectedFailure final {}; + +// Faults affect only the coordinator's call sites in this test translation +// unit. Other production modules continue to use their real implementations. +thread_local const Plan* active = nullptr; +thread_local bool restoring = false; +thread_local int cleanup_failures = 0; +thread_local int retry_calls = 0; +thread_local bool retry_restored = false; +thread_local int error_messages = 0; + +class Scope final { +public: + explicit Scope(const Plan& plan) noexcept + : previous_(active), previous_restoring_(restoring), previous_failures_(cleanup_failures), + previous_retry_calls_(retry_calls), previous_retry_restored_(retry_restored), + previous_messages_(error_messages) { + active = &plan; + restoring = false; + cleanup_failures = plan.cleanup_failures; + retry_calls = 0; + retry_restored = false; + error_messages = 0; + } + ~Scope() noexcept { + active = previous_; + restoring = previous_restoring_; + cleanup_failures = previous_failures_; + retry_calls = previous_retry_calls_; + retry_restored = previous_retry_restored_; + error_messages = previous_messages_; + } + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + Scope(Scope&&) = delete; + Scope& operator=(Scope&&) = delete; +private: + const Plan* previous_; + bool previous_restoring_; + int previous_failures_; + int previous_retry_calls_; + bool previous_retry_restored_; + int previous_messages_; +}; + +LPCWSTR command_line() { + if (active != nullptr && active->unexpected_startup_failure) throw UnexpectedFailure{}; + return active != nullptr && !active->entry_command.empty() + ? active->entry_command.c_str() : ::GetCommandLineW(); +} + +LPWSTR* WINAPI parse_command_line(LPCWSTR command, int* count) { + if (active != nullptr && active->parse_failure) { + SetLastError(ERROR_NOT_ENOUGH_MEMORY); + return nullptr; + } + return ::CommandLineToArgvW(command, count); +} + +DWORD WINAPI module_name(HMODULE module, LPWSTR buffer, DWORD size) { + if (active != nullptr && active->executable_failure) { + SetLastError(ERROR_INVALID_HANDLE); + return 0; + } + return ::GetModuleFileNameW(module, buffer, size); +} + +HANDLE WINAPI session_mutex(LPSECURITY_ATTRIBUTES security, BOOL owner, LPCWSTR name) { + if (active == nullptr || active->entry_command.empty()) return ::CreateMutexW(security, owner, name); + if (name == nullptr || std::wstring_view(name) != L"Global\\D2SteamFix-1085660") { + throw std::logic_error("unexpected fixture session mutex"); + } + const HANDLE handle = ::CreateMutexW(security, owner, nullptr); + if (handle != nullptr && active->existing_session) SetLastError(ERROR_ALREADY_EXISTS); + return handle; +} + +DWORD WINAPI set_security(const HANDLE handle, const SE_OBJECT_TYPE type, const SECURITY_INFORMATION information, + PSID owner, PSID group, PACL dacl, PACL sacl) { + if (active != nullptr && restoring && cleanup_failures > 0) { + --cleanup_failures; + return ERROR_ACCESS_DENIED; + } + return ::SetSecurityInfo(handle, type, information, owner, group, dacl, sacl); +} + +BOOL WINAPI create_process(LPCWSTR application, LPWSTR command, LPSECURITY_ATTRIBUTES process_security, + LPSECURITY_ATTRIBUTES thread_security, BOOL inherit, DWORD flags, + LPVOID environment, LPCWSTR directory, LPSTARTUPINFOW startup, + LPPROCESS_INFORMATION information) { + if (active != nullptr && active->kind != Kind::real_launch) { + SetLastError(active->error); + return FALSE; + } + return ::CreateProcessW(application, command, process_security, thread_security, inherit, + flags, environment, directory, startup, information); +} + +} // namespace app_fault + +namespace steamfix::elevation { + +std::optional app_test_accept_launch(std::vector& arguments) { + if (app_fault::active == nullptr || app_fault::active->entry_command.empty()) return accept_elevated_launch(arguments); + if (arguments.size() != 2 || fs::path(arguments.front()) != app_fault::active->launcher || + arguments.back() != L"--fixture-launcher") { + throw std::logic_error("entry fixture did not supply its exact owned launcher command"); + } + return app_fault::active->steam; +} + +std::optional app_test_relaunch(const std::vector& arguments, + const fs::path& directory, const fs::path& steam_directory); + +bool app_test_is_elevated() { + return app_fault::active != nullptr ? app_fault::active->elevated : is_elevated(); +} + +bool app_test_desktop_launch(const std::filesystem::path& executable, const std::wstring& command, + const std::filesystem::path& directory, PROCESS_INFORMATION& information) { + if (app_fault::active == nullptr) return launch_as_desktop_user(executable, command, directory, information); + app_fault::restoring = true; + switch (app_fault::active->kind) { + case app_fault::Kind::process_error: + case app_fault::Kind::real_launch: + return false; + case app_fault::Kind::cancelled: + throw Cancelled(); + case app_fault::Kind::relaunch: + throw RelaunchRequired(app_fault::active->error, directory, executable, app_fault::active->steam); + case app_fault::Kind::standard_error: + throw std::runtime_error("injected coordinator launch failure"); + case app_fault::Kind::unexpected_error: + throw app_fault::UnexpectedFailure{}; + } + throw std::logic_error("unknown launch-fault kind"); +} + +} // namespace steamfix::elevation + +// Only ACL operations from this included implementation see the finite cleanup +// fault. Targets are always the fixture renderers supplied by check_session. +#define SetSecurityInfo app_fault::set_security +#include "../src/acl.cpp" +#undef SetSecurityInfo + +#define CreateProcessW app_fault::create_process +#define is_elevated app_test_is_elevated +#define launch_as_desktop_user app_test_desktop_launch +#define accept_elevated_launch app_test_accept_launch +#define relaunch_as_administrator app_test_relaunch +#define GetCommandLineW app_fault::command_line +#define CommandLineToArgvW app_fault::parse_command_line +#define GetModuleFileNameW app_fault::module_name +#define CreateMutexW app_fault::session_mutex +#include "../src/app.cpp" +#undef CreateMutexW +#undef GetModuleFileNameW +#undef CommandLineToArgvW +#undef GetCommandLineW +#undef relaunch_as_administrator +#undef accept_elevated_launch +#undef CreateProcessW +#undef is_elevated +#undef launch_as_desktop_user + +namespace steamfix::elevation { + +std::optional app_test_relaunch(const std::vector& arguments, + const fs::path& directory, const fs::path& steam_directory) { + if (app_fault::active == nullptr || app_fault::active->entry_command.empty()) { + return relaunch_as_administrator(arguments, directory, steam_directory); + } + ++app_fault::retry_calls; + if (arguments.size() != 2 || !paths::equal_windows(fs::path(arguments.front()), app_fault::active->launcher) || + arguments.back() != L"--fixture-launcher" || + !paths::equal_windows(directory, app_fault::active->launcher.parent_path()) || + !paths::equal_windows(steam_directory, app_fault::active->steam) || + fs::exists(directory / L"steamfix.dat")) { + throw std::logic_error("administrator retry preceded restoration or escaped its fixture"); + } + const Logger log(directory / L"steamfix.log"); + if (repair::needed(steam_directory, log)) throw std::logic_error("administrator retry retained execute denial"); + app_fault::retry_restored = true; + switch (app_fault::active->retry) { + case app_fault::Retry::status: return 23; + case app_fault::Retry::unknown_status: return std::nullopt; + case app_fault::Retry::cancelled: throw Cancelled(); + case app_fault::Retry::failure: throw std::runtime_error("controlled administrator launch failure"); + } + throw std::logic_error("unknown administrator retry fixture"); +} + +} // namespace steamfix::elevation + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void require(const bool value, const char* message) { + if (!value) throw std::runtime_error(message); +} + +template +void expect_error(const steamfix::ExitCode code, Function&& action) { + try { + action(); + } catch (const steamfix::AppError& error) { + require(error.code() == code, "unexpected application error code"); + return; + } + throw std::runtime_error("expected an application error"); +} + +class TestDirectory final { +public: + TestDirectory() { + GUID identifier{}; + require(SUCCEEDED(CoCreateGuid(&identifier)), "create fixture identifier"); + std::array text{}; + require(StringFromGUID2(identifier, text.data(), static_cast(text.size())) != 0, + "format fixture identifier"); + path_ = fs::temp_directory_path() / (L"D2SteamFix app tests " + std::wstring(text.data())); + require(fs::create_directory(path_), "create fixture directory"); + } + ~TestDirectory() noexcept { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + TestDirectory(const TestDirectory&) = delete; + TestDirectory& operator=(const TestDirectory&) = delete; + TestDirectory(TestDirectory&&) = delete; + TestDirectory& operator=(TestDirectory&&) = delete; + const fs::path& path() const noexcept { return path_; } +private: + fs::path path_; +}; + +class Environment final { +public: + Environment(const wchar_t* name, const wchar_t* value) + : name_(name), previous_(steamfix::app::environment(name)) { + require(SetEnvironmentVariableW(name_.c_str(), value) != FALSE, "set fixture environment"); + } + ~Environment() noexcept { + SetEnvironmentVariableW(name_.c_str(), previous_ ? previous_->c_str() : nullptr); + } + Environment(const Environment&) = delete; + Environment& operator=(const Environment&) = delete; + Environment(Environment&&) = delete; + Environment& operator=(Environment&&) = delete; +private: + std::wstring name_; + std::optional previous_; +}; + +void write_file(const fs::path& path, const std::string_view contents = "fixture") { + std::ofstream stream(path, std::ios::binary | std::ios::trunc); + stream << contents; + require(static_cast(stream), "write fixture file"); +} + +std::string read_file(const fs::path& path) { + std::ifstream stream(path, std::ios::binary); + require(static_cast(stream), "read fixture file"); + return {std::istreambuf_iterator{stream}, std::istreambuf_iterator{}}; +} + +void check_errors() { + using steamfix::AppError; + using steamfix::ExitCode; + static_assert(std::is_nothrow_copy_constructible_v); + static_assert(std::is_nothrow_copy_assignable_v); + static_assert(std::is_nothrow_move_constructible_v); + static_assert(std::is_nothrow_move_assignable_v); + AppError error(ExitCode::validation, "validation", "detail"); + require(std::string_view{error.what()} == "validation: detail", "exception message"); + require(!error.launcher_started(), "initial launch state"); + error.after_launch(); + AppError copied(error); + AppError moved(std::move(error)); + copied = moved; + copied = std::move(moved); + // AppError explicitly implements move as a nonthrowing copy, retaining both + // accessor invariants. This assertion intentionally checks that contract. +#ifdef _MSC_VER +#pragma warning(suppress : 26800) +#endif + require(error.stage() == "validation" && moved.detail() == "detail" && copied.launcher_started(), + "exception copies and moves preserve context"); + const auto captured = std::make_exception_ptr(copied); + try { + std::rethrow_exception(captured); + } catch (const AppError& caught) { + require(caught.detail() == "detail" && caught.launcher_started(), "exception_ptr context"); + } + require(steamfix::app::failure_message(copied).find(L"launcher was started") != std::wstring::npos, + "post-launch message"); + const AppError before(ExitCode::validation, "validation", "detail"); + require(steamfix::app::failure_message(before).find(L"was not started") != std::wstring::npos, + "pre-launch message"); + const AppError invocation(ExitCode::invalid_invocation, "invocation", "exact detail"); + require(steamfix::app::failure_message(invocation) == L"exact detail", "invocation message"); + const AppError repair(ExitCode::internal, "Steam file repair", "repair detail"); + require(steamfix::app::failure_message(repair).find(L"repair detail") != std::wstring::npos, + "repair message"); +} + +void check_validation(const fs::path& directory) { + using namespace steamfix; + const std::array, 3> ids{std::nullopt, L"1085660", L"7"}; + for (const auto& app_value : ids) { + for (const auto& game : ids) { + const bool expected = (app_value != ids[2] && game != ids[2]) && + (app_value == ids[1] || game == ids[1]); + require(app::validate_app_ids(app_value, game) == expected, "Steam App ID combinations"); + } + } + require(app::optional_id(std::nullopt) == L"None" && + app::optional_id(std::wstring{L"1085660"}) == L"Some(\"1085660\")", "optional ID display"); + expect_error(ExitCode::invalid_invocation, [] { app::validate_invocation({}, {}, {}); }); + expect_error(ExitCode::validation, [] { app::resolve_launcher(L"other.exe"); }); + expect_error(ExitCode::validation, [&] { app::resolve_launcher((directory / L"destiny2launcher.exe").native()); }); + const fs::path launcher = directory / L"destiny2launcher.exe"; + fs::create_directory(launcher); + expect_error(ExitCode::validation, [&] { app::resolve_launcher(launcher.native()); }); + fs::remove(launcher); + write_file(launcher); + expect_error(ExitCode::validation, [&] { app::validate_invocation({launcher.native()}, ids[1], {}); }); + write_file(directory / L"destiny2.exe"); + expect_error(ExitCode::validation, [&] { app::validate_invocation({launcher.native()}, {}, {}); }); + const auto invocation = app::validate_invocation({launcher.native(), L"two words", L""}, ids[1], {}); + require(paths::equal_windows(invocation.game_dir, directory) && invocation.args.size() == 2 && + invocation.args.front() == L"two words" && invocation.args.back().empty(), "forwarded arguments"); + const fs::path old_directory = fs::current_path(); + fs::current_path(directory); + try { + const auto relative = app::validate_invocation({L"destiny2launcher.exe"}, {}, ids[1]); + require(relative.launcher.is_absolute() && paths::equal_windows(relative.game_dir, directory), + "relative launcher resolves before working directory changes"); + } catch (...) { + fs::current_path(old_directory); + throw; + } + fs::current_path(old_directory); + require(app::existing_processes({}) == "verified Destiny processes already running: []", "empty process display"); + require(app::existing_processes({{1, L"destiny2.exe"}, {2, L"destiny2launcher.exe"}}).find(", (2,") != + std::string::npos, "multiple process display"); + app::check_overlay(); + const auto mutex = app::session_mutex(); + expect_error(ExitCode::existing_session, [] { static_cast(app::session_mutex()); }); +} + +void check_environment() { + constexpr auto name = L"D2STEAMFIX_TEST_VALUE"; + const Environment guard(name, nullptr); + require(!steamfix::app::environment(name), "missing environment value"); + require(SetEnvironmentVariableW(name, L"") != FALSE, "set empty environment"); + require(steamfix::app::environment(name) == std::wstring{}, "empty environment value"); + require(SetEnvironmentVariableW(name, L"two words 淇京") != FALSE, "set Unicode environment"); + require(steamfix::app::environment(name) == std::wstring{L"two words 淇京"}, "Unicode environment value"); + require(steamfix::app::current_executable().is_absolute(), "current executable path"); + require(steamfix::app::command_line_arguments() == std::vector{L"--entry-check"}, + "command-line argument ownership"); +} + +void check_logging(const fs::path& directory) { + const fs::path path = directory / L"steamfix.log"; + { + const steamfix::Logger log(path); + log.info("first\nsecond\n"); + log.warn(""); + log.error("failure"); + std::array writers; + for (auto& writer : writers) { + writer = std::jthread([&] { for (int i = 0; i < 10; ++i) log.info("parallel complete line"); }); + } + } + const std::string text = read_file(path); + require(text.find("[INFO]") != std::string::npos && text.find("[WARN]") != std::string::npos && + text.find("[ERROR]") != std::string::npos && text.find("first\n") != std::string::npos, + "log levels and multiline formatting"); + require(std::count(text.begin(), text.end(), '\n') == 45, "concurrent log messages stay complete"); + const fs::path linked = directory / L"hardlink.log"; + require(CreateHardLinkW(linked.c_str(), path.c_str(), nullptr) != FALSE, "create log hardlink fixture"); + try { + const steamfix::Logger unsafe(path); + throw std::logic_error("expected linked log rejection"); + } catch (const std::system_error& error) { + require(error.code().category() == std::system_category() && + error.code().value() == ERROR_INVALID_DATA, "hardlinked log rejected before rotation"); + } + expect_error(steamfix::ExitCode::log_initialization, + [&] { const auto rejected = steamfix::app::create_log(path); }); + fs::remove(linked); +} + +int fixture_launcher() { + const fs::path game = steamfix::app::current_executable().parent_path() / L"destiny2.exe"; + std::wstring command; + steamfix::command_line::append_quoted(command, game.native()); + steamfix::command_line::append_quoted(command, L"--fixture-game"); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + require(CreateProcessW(game.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, + game.parent_path().c_str(), &startup, &information) != FALSE, "launch fixture game"); + const steamfix::UniqueHandle process(information.hProcess); + const steamfix::UniqueHandle thread(information.hThread); + require(WaitForSingleObject(process.get(), 10000) == WAIT_OBJECT_0, "fixture game exits"); + return 7; +} + +int authenticated_entry(const fs::path& steam, const fs::path& launcher) { + // An already elevated test can exercise the authenticated coordinator + // handoff with CreateProcess, avoiding any runas/UAC prompt or registry edit. + const fs::path self = steamfix::app::current_executable(); + std::wstring command; + for (const auto& argument : std::array{self.native(), std::wstring{L"--steamfix-elevated-launch"}, + std::to_wstring(GetCurrentProcessId()), steam.native(), + launcher.native(), std::wstring{L"--fixture-launcher"}}) { + steamfix::command_line::append_quoted(command, argument); + } + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + require(CreateProcessW(self.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr, + steam.parent_path().c_str(), &startup, &information) != FALSE, + "create authenticated entry fixture"); + const steamfix::UniqueHandle child(information.hProcess); + const steamfix::UniqueHandle thread(information.hThread); + if (WaitForSingleObject(child.get(), 45000) != WAIT_OBJECT_0) { + // This is the child just created above, never a discovered process. + TerminateProcess(child.get(), 99); + WaitForSingleObject(child.get(), 5000); + throw std::runtime_error("authenticated entry fixture did not finish"); + } + DWORD status{}; + require(GetExitCodeProcess(child.get(), &status) != FALSE, "read entry fixture status"); + return steamfix::windows_exit_status(status); +} + +class SuspendedChild final { +public: + explicit SuspendedChild(const fs::path& executable) { + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + require(CreateProcessW(executable.c_str(), nullptr, nullptr, nullptr, FALSE, + CREATE_SUSPENDED | CREATE_NO_WINDOW, nullptr, + executable.parent_path().c_str(), &startup, &information) != FALSE, + "create already-running fixture child"); + process_ = steamfix::UniqueHandle(information.hProcess); + const steamfix::UniqueHandle thread(information.hThread); + } + ~SuspendedChild() noexcept { + if (alive()) { + // Only the handle returned by this object's CreateProcess is used. + TerminateProcess(process_.get(), ERROR_CANCELLED); + WaitForSingleObject(process_.get(), 5000); + } + } + SuspendedChild(const SuspendedChild&) = delete; + SuspendedChild& operator=(const SuspendedChild&) = delete; + SuspendedChild(SuspendedChild&&) = delete; + SuspendedChild& operator=(SuspendedChild&&) = delete; + [[nodiscard]] bool alive() const noexcept { return WaitForSingleObject(process_.get(), 0) == WAIT_TIMEOUT; } +private: + steamfix::UniqueHandle process_; +}; + +void check_session(const fs::path& directory) { + using namespace steamfix; + const Environment app_id(L"SteamAppId", L"1085660"); + const Environment game_id(L"SteamGameId", nullptr); + const fs::path game = directory / L"game with spaces"; + const fs::path steam = directory / L"steam with spaces"; + fs::create_directory(game); + fs::create_directory(steam); + const fs::path self = app::current_executable(); + const fs::path launcher = game / L"destiny2launcher.exe"; + fs::copy_file(self, launcher); + fs::copy_file(self, game / L"destiny2.exe"); + write_file(steam / L"steam.exe"); + const fs::path renderer = steam / L"GameOverlayRenderer64.dll"; + write_file(renderer, "immutable renderer contents"); + const fs::path renderer32 = steam / L"GameOverlayRenderer.dll"; + write_file(renderer32, "immutable 32-bit contents"); + const Logger log(game / L"steamfix.log"); + const fs::path recovery = acl::recovery_path(game); + const std::vector arguments{launcher.native(), L"--fixture-launcher"}; + require(app::run(log, arguments, recovery, steam) == 7, "launcher status survives protected session"); + if (elevation::is_elevated()) { + const int status = authenticated_entry(steam, launcher); + if (status != 7) { + throw std::runtime_error("authenticated application entry failed with status " + + std::to_string(status) + ": " + read_file(game / L"steamfix.log")); + } + const fs::path linked_log = game / L"linked.log"; + require(CreateHardLinkW(linked_log.c_str(), (game / L"steamfix.log").c_str(), nullptr) != FALSE, + "create entry log-failure fixture"); + require(authenticated_entry(steam, launcher) == static_cast(ExitCode::log_initialization), + "entry retains log initialization error code"); + fs::remove(linked_log); + } else { + std::cout << "SKIP: authenticated elevated entry requires an already elevated test token.\n"; + } + require(!fs::exists(recovery) && read_file(renderer) == "immutable renderer contents" && + read_file(renderer32) == "immutable 32-bit contents", "session restores and cleans journal"); + { + const SuspendedChild existing(game / L"destiny2.exe"); + expect_error(ExitCode::existing_session, [&] { app::run(log, arguments, recovery, steam); }); + require(existing.alive() && !fs::exists(recovery) && !repair::needed(steam, log), + "an existing verified game prevents permission changes and remains running"); + } + { + const fs::path loaded_directory = directory / L"loaded overlay fixture"; + fs::create_directory(loaded_directory); + const fs::path module = loaded_directory / L"GameOverlayRenderer64.dll"; + fs::copy_file(self.parent_path() / L"overlay_fixture.dll", module); + const std::unique_ptr, decltype(&FreeLibrary)> + loaded(LoadLibraryW(module.c_str()), &FreeLibrary); + require(loaded != nullptr, "load owned overlay fixture into the test process"); + expect_error(ExitCode::overlay_loaded, [&] { app::run(log, arguments, recovery, steam); }); + require(!fs::exists(recovery) && !repair::needed(steam, log), + "a loaded overlay prevents permission changes and launcher creation"); + } + app::check_overlay(); + const auto check_restored = [&] { + require(!fs::exists(recovery) && !repair::needed(steam, log) && + read_file(renderer) == "immutable renderer contents", "launch faults preserve original permissions"); + }; + for (const auto kind : {app_fault::Kind::cancelled, app_fault::Kind::relaunch, + app_fault::Kind::standard_error, app_fault::Kind::unexpected_error}) { + const app_fault::Plan plan{kind, ERROR_ACCESS_DENIED, false, steam}; + const app_fault::Scope faults(plan); + bool caught = false; + try { + app::run(log, arguments, recovery, steam); + } catch (const elevation::Cancelled&) { + caught = kind == app_fault::Kind::cancelled; + } catch (const elevation::RelaunchRequired& request) { + caught = kind == app_fault::Kind::relaunch && paths::equal_windows(request.launcher(), launcher) && + paths::equal_windows(request.steam_directory(), steam); + } catch (const AppError& error) { + caught = (kind == app_fault::Kind::standard_error || kind == app_fault::Kind::unexpected_error) && + error.code() == ExitCode::internal && !error.launcher_started(); + } + require(caught, "coordinator preserves injected failure identity after restoration"); + check_restored(); + } + for (const DWORD error : {ERROR_ACCESS_DENIED, ERROR_PRIVILEGE_NOT_HELD, ERROR_ELEVATION_REQUIRED}) { + for (const bool elevated : {false, true}) { + const app_fault::Plan plan{app_fault::Kind::process_error, error, elevated, steam}; + const app_fault::Scope faults(plan); + bool caught = false; + try { + app::run(log, arguments, recovery, steam); + } catch (const elevation::RelaunchRequired& request) { + caught = !elevated && paths::equal_windows(request.directory(), game); + } catch (const AppError& failure) { + caught = elevated && failure.code() == ExitCode::process_creation && !failure.launcher_started(); + } + require(caught, "permission failure selects administrator retry only when appropriate"); + check_restored(); + } + } + for (const auto kind : {app_fault::Kind::standard_error, app_fault::Kind::unexpected_error, + app_fault::Kind::real_launch}) { + const app_fault::Plan plan{kind, ERROR_ACCESS_DENIED, false, steam, 1}; + const app_fault::Scope faults(plan); + bool caught = false; + try { + app::run(log, arguments, recovery, steam); + } catch (const AppError& error) { + caught = error.code() == ExitCode::overlay_protection && + error.launcher_started() == (kind == app_fault::Kind::real_launch); + const bool preceding = error.detail().find("preceding session failure") != std::string::npos; + require(preceding == (kind != app_fault::Kind::real_launch), + "cleanup reporting retains preceding failures without inventing one"); + } + require(caught && app_fault::cleanup_failures == 0, "cleanup failure keeps correct session context"); + // Explicit cleanup reports the failure; the resource destructor retries + // the consumed transient fault and restores the original permissions. + check_restored(); + } + std::wstring entry_command; + command_line::append_quoted(entry_command, self.native()); + for (const auto& argument : arguments) command_line::append_quoted(entry_command, argument); + for (const auto retry : {app_fault::Retry::status, app_fault::Retry::unknown_status, + app_fault::Retry::cancelled, app_fault::Retry::failure}) { + const app_fault::Plan plan{app_fault::Kind::relaunch, ERROR_ACCESS_DENIED, false, steam, + 0, entry_command, launcher, retry}; + const app_fault::Scope faults(plan); + const int expected = retry == app_fault::Retry::status ? 23 : + retry == app_fault::Retry::failure ? static_cast(ExitCode::process_creation) : 0; + const int status = app::entry(); + if (status != expected || app_fault::retry_calls != 1 || !app_fault::retry_restored) { + throw std::runtime_error("entry administrator retry: status=" + std::to_string(status) + + ", expected=" + std::to_string(expected) + ", calls=" + + std::to_string(app_fault::retry_calls) + ", restored=" + + std::to_string(app_fault::retry_restored) + ": " + + read_file(game / L"steamfix.log")); + } + require(app_fault::error_messages == (retry == app_fault::Retry::failure ? 1 : 0), + "entry reports administrator retry failures without treating cancellation as an error"); + check_restored(); + } + for (const auto kind : {app_fault::Kind::process_error, app_fault::Kind::cancelled, + app_fault::Kind::standard_error, app_fault::Kind::unexpected_error}) { + const app_fault::Plan plan{kind, ERROR_BAD_EXE_FORMAT, false, steam, 0, entry_command, launcher}; + const app_fault::Scope faults(plan); + const int expected = kind == app_fault::Kind::cancelled ? 0 : + static_cast(kind == app_fault::Kind::process_error ? + ExitCode::process_creation : ExitCode::internal); + require(app::entry() == expected && app_fault::retry_calls == 0, + "entry translates coordinator failures without requesting administrator launch"); + require(app_fault::error_messages == (kind == app_fault::Kind::cancelled ? 0 : 1), + "entry emits exactly one diagnostic for a noncancelled failure"); + check_restored(); + } + for (int failure = 0; failure < 4; ++failure) { + const app_fault::Plan plan{app_fault::Kind::process_error, ERROR_BAD_EXE_FORMAT, false, steam, + 0, entry_command, launcher, app_fault::Retry::status, + failure == 0, failure == 1, failure == 2, failure == 3}; + const app_fault::Scope faults(plan); + const int expected = static_cast(failure == 0 ? ExitCode::invalid_invocation : + failure == 2 ? ExitCode::existing_session : ExitCode::internal); + require(app::entry() == expected && app_fault::retry_calls == 0 && app_fault::error_messages == 1, + "entry rejects startup failures inside its nonthrowing boundary"); + check_restored(); + } + // A valid path with invalid executable bytes forces CreateProcess failure + // after the recovery journal and execute denial have been established. + write_file(launcher, "invalid executable"); + expect_error(ExitCode::process_creation, [&] { app::run(log, arguments, recovery, steam); }); + require(!fs::exists(recovery) && !repair::needed(steam, log), "launch failure restores execute access"); + fs::remove(renderer32); + expect_error(ExitCode::process_creation, [&] { app::run(log, arguments, recovery, steam); }); + require(!fs::exists(recovery), "optional 32-bit absence preserves cleanup"); + write_file(recovery, "invalid journal"); + expect_error(ExitCode::overlay_protection, [&] { app::run(log, arguments, recovery, steam); }); + require(read_file(recovery) == "invalid journal", "invalid journal preserved"); + fs::remove(recovery); + fs::remove(renderer); + expect_error(ExitCode::steam_discovery, [&] { app::run(log, arguments, recovery, steam); }); +} + +} // namespace + +// Unit tests capture dialogs to keep CI noninteractive. ui.cpp is still included +// separately in the coverage inventory, so its untested lines remain visible. +namespace steamfix::ui { +void info(std::wstring_view) noexcept {} +void error(std::wstring_view) noexcept { + if (app_fault::active != nullptr) ++app_fault::error_messages; +} +} + +int main() { + try { + const auto arguments = steamfix::app::command_line_arguments(); + if (!arguments.empty() && arguments.front() == L"--steamfix-elevated-launch") { + return steamfix::app::entry(); + } + if (arguments == std::vector{L"--fixture-game"}) { + Sleep(2000); + return 0; + } + if (arguments == std::vector{L"--fixture-launcher"}) return fixture_launcher(); + require(arguments == std::vector{L"--entry-check"}, "test invocation"); + const TestDirectory fixture; + check_errors(); + check_environment(); + check_validation(fixture.path()); + check_logging(fixture.path()); + check_session(fixture.path()); + require(steamfix::app::entry() == static_cast(steamfix::ExitCode::validation), + "entry rejects unrelated forwarded executable without escape"); + } catch (const std::exception& error) { + std::cerr << "Application tests failed: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/tests/command_line_tests.cpp b/tests/command_line_tests.cpp new file mode 100644 index 0000000..5864b6b --- /dev/null +++ b/tests/command_line_tests.cpp @@ -0,0 +1,99 @@ +#include "steamfix/command_line.hpp" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include + +#include +#include +#endif + +namespace { + +struct QuotingCase { + std::wstring_view argument; + std::wstring_view expected; +}; + +constexpr std::array cases{ + QuotingCase{L"plain", L"plain"}, + QuotingCase{L"", LR"("")"}, + QuotingCase{L"two words", LR"("two words")"}, + QuotingCase{L"a\tb", L"\"a\tb\""}, + QuotingCase{L"line\nbreak", L"line\nbreak"}, + QuotingCase{LR"(say"hi)", LR"("say\"hi")"}, + QuotingCase{LR"(C:\plain\)", LR"(C:\plain\)"}, + QuotingCase{LR"(C:\two words\)", LR"("C:\two words\\")"}, + QuotingCase{LR"(a\"b)", LR"("a\\\"b")"}, + QuotingCase{LR"(a\\"b)", LR"("a\\\\\"b")"}, + QuotingCase{L"淇京 馃幃", L"\"淇京 馃幃\""}, +}; + +bool check_expected_quoting() { + for (const auto& test : cases) { + std::wstring line; + steamfix::command_line::append_quoted(line, test.argument); + if (line != test.expected) { + std::wcerr << L"Unexpected quoting for argument: " << test.argument << L'\n'; + return false; + } + } + + std::wstring line{L"steamfix.exe"}; + steamfix::command_line::append_quoted(line, L""); + steamfix::command_line::append_quoted(line, L"two words"); + if (line != LR"(steamfix.exe "" "two words")") { + std::wcerr << L"Appending arguments did not preserve separators.\n"; + return false; + } + return true; +} + +#ifdef _WIN32 +struct CommandLineDeleter { + void operator()(wchar_t** arguments) const noexcept { + LocalFree(arguments); + } +}; + +bool check_windows_round_trip() { + std::wstring line{L"steamfix.exe"}; + for (const auto& test : cases) { + steamfix::command_line::append_quoted(line, test.argument); + } + + int count{}; + const std::unique_ptr arguments{ + CommandLineToArgvW(line.c_str(), &count)}; + if (!arguments || count <= 0 || static_cast(count) != cases.size() + 1U) { + std::wcerr << L"Windows could not parse the generated command line.\n"; + return false; + } + const std::span parsed{arguments.get(), static_cast(count)}; + auto argument = parsed.subspan(1).begin(); + for (const auto& test : cases) { + if (std::wstring_view{*argument} != test.argument) { + std::wcerr << L"Windows round trip changed an argument: " << test.argument << L'\n'; + return false; + } + ++argument; + } + return true; +} +#endif + +} // namespace + +int main() { + if (!check_expected_quoting()) return 1; +#ifdef _WIN32 + if (!check_windows_round_trip()) return 1; +#endif + return 0; +} diff --git a/tests/elevation_tests.cpp b/tests/elevation_tests.cpp new file mode 100644 index 0000000..aabc551 --- /dev/null +++ b/tests/elevation_tests.cpp @@ -0,0 +1,1215 @@ +// Exercise the actual private protocol and Win32 helpers without starting UAC. +#include "steamfix/elevation.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace elevation_fault { +BOOL WINAPI shell_execute(SHELLEXECUTEINFOW* execute); +BOOL WINAPI token_information(HANDLE token, TOKEN_INFORMATION_CLASS information_class, + LPVOID information, DWORD bytes, PDWORD returned); +HANDLE WINAPI create_file(LPCWSTR path, DWORD access, DWORD share, LPSECURITY_ATTRIBUTES security, + DWORD creation, DWORD flags, HANDLE source); +HANDLE WINAPI open_process(DWORD access, BOOL inherit, DWORD pid); +} + +// The shell consent boundary, selected parent's elevation report, and exact +// one-shot permission failures are simulated. Successful I/O, authentication, +// child token reads, handle duplication, and waits use real Windows operations. +#define ShellExecuteExW ::elevation_fault::shell_execute +#define GetTokenInformation ::elevation_fault::token_information +#define CreateFileW ::elevation_fault::create_file +#define OpenProcess ::elevation_fault::open_process +#include "../src/elevation.cpp" +#undef OpenProcess +#undef CreateFileW +#undef GetTokenInformation +#undef ShellExecuteExW + +namespace elevation_fault { + +constexpr std::wstring_view exit_switch = L"--steamfix-test-shell-exit"; +constexpr std::wstring_view relaunch_switch = L"--steamfix-test-handoff"; +constexpr DWORD child_status = 37; + +void check(const bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +class ChildGuardian final { +public: + explicit ChildGuardian(const HANDLE process) + : process_(duplicate(process)), wake_(CreateEventW(nullptr, TRUE, FALSE, nullptr)), + watchdog_([this] { + const std::array waits{process_.get(), wake_.get()}; + if (WaitForMultipleObjects(static_cast(waits.size()), waits.data(), FALSE, 10000) == WAIT_TIMEOUT) { + timed_out_.store(true); + TerminateProcess(process_.get(), ERROR_TIMEOUT); + } + }) {} + ~ChildGuardian() noexcept { + SetEvent(wake_.get()); + if (WaitForSingleObject(process_.get(), 0) == WAIT_TIMEOUT) { + TerminateProcess(process_.get(), ERROR_CANCELLED); + WaitForSingleObject(process_.get(), 3000); + } + // watchdog_ joins before its event and process-handle owners disappear. + } + ChildGuardian(const ChildGuardian&) = delete; + ChildGuardian& operator=(const ChildGuardian&) = delete; + ChildGuardian(ChildGuardian&&) = delete; + ChildGuardian& operator=(ChildGuardian&&) = delete; + DWORD wait() const { + check(WaitForSingleObject(process_.get(), 10000) == WAIT_OBJECT_0, "The owned shell child did not exit"); + check(!timed_out_.load(), "The owned shell child exceeded its ten-second watchdog"); + DWORD status{}; + if (!GetExitCodeProcess(process_.get(), &status)) steamfix::elevation::fail(GetLastError()); + return status; + } + void terminate() const { + if (!TerminateProcess(process_.get(), ERROR_CANCELLED)) steamfix::elevation::fail(GetLastError()); + check(wait() == ERROR_CANCELLED, "The exclusively owned helper did not terminate as requested"); + } +private: + static steamfix::UniqueHandle duplicate(const HANDLE process) { + HANDLE result{}; + if (!DuplicateHandle(GetCurrentProcess(), process, GetCurrentProcess(), &result, + PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, 0)) { + steamfix::elevation::fail(GetLastError()); + } + return steamfix::UniqueHandle(result); + } + steamfix::UniqueHandle process_; + steamfix::UniqueHandle wake_; + std::atomic timed_out_ = false; + std::jthread watchdog_; +}; + +enum class Mode { launch, cancel, exit_before_connection }; + +struct Plan { + std::filesystem::path self; + std::filesystem::path directory; + bool report_unelevated = false; + Mode mode = Mode::launch; + std::size_t shell_calls = 0; + std::filesystem::path denied_file; + unsigned file_denials = 0; + DWORD denied_pid = 0; + unsigned process_denials = 0; + std::vector> children; +}; + +thread_local Plan* active = nullptr; + +bool real_elevated() { + HANDLE raw{}; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw)) steamfix::elevation::fail(GetLastError()); + const steamfix::UniqueHandle token(raw); + TOKEN_ELEVATION information{}; + DWORD returned{}; + if (!::GetTokenInformation(token.get(), TokenElevation, &information, sizeof(information), &returned)) { + steamfix::elevation::fail(GetLastError()); + } + return information.TokenIsElevated != 0; +} + +class Scope final { +public: + explicit Scope(Plan& plan) : previous_(active) { + check(real_elevated(), "The simulated shell boundary requires a real elevated parent token"); + active = &plan; + } + ~Scope() noexcept { active = previous_; } + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + Scope(Scope&&) = delete; + Scope& operator=(Scope&&) = delete; +private: + Plan* previous_; +}; + +BOOL WINAPI token_information(const HANDLE token, const TOKEN_INFORMATION_CLASS information_class, + const LPVOID information, const DWORD bytes, const PDWORD returned) { + const BOOL result = ::GetTokenInformation(token, information_class, information, bytes, returned); + if (result && active != nullptr && active->report_unelevated && information_class == TokenElevation) { + check(information != nullptr && bytes >= sizeof(TOKEN_ELEVATION), "Invalid elevation-report fixture buffer"); + auto& elevation = *static_cast(information); + check(elevation.TokenIsElevated != 0, "A fixture attempted to simulate elevation from an unelevated token"); + elevation.TokenIsElevated = 0; + } + return result; +} + +HANDLE WINAPI create_file(const LPCWSTR path, const DWORD access, const DWORD share, + const LPSECURITY_ATTRIBUTES security, const DWORD creation, + const DWORD flags, const HANDLE source) { + if (active != nullptr && active->file_denials != 0 && path != nullptr && + steamfix::paths::equal_windows(std::filesystem::path(path), active->denied_file)) { + check(active->file_denials == 1 && access == FILE_READ_DATA && share == FILE_SHARE_READ && + security == nullptr && creation == OPEN_EXISTING && flags == FILE_ATTRIBUTE_NORMAL && source == nullptr, + "A file-denial fixture escaped its exact initial renderer request"); + --active->file_denials; + SetLastError(ERROR_ACCESS_DENIED); + return INVALID_HANDLE_VALUE; + } + return ::CreateFileW(path, access, share, security, creation, flags, source); +} + +HANDLE WINAPI open_process(const DWORD access, const BOOL inherit, const DWORD pid) { + if (active != nullptr && active->process_denials != 0 && pid == active->denied_pid) { + check(active->process_denials == 1 && access == steamfix::elevation::terminate_access && !inherit, + "A process-denial fixture escaped its exact initial owned-process request"); + --active->process_denials; + SetLastError(ERROR_PRIVILEGE_NOT_HELD); + return nullptr; + } + return ::OpenProcess(access, inherit, pid); +} + +bool validate_shell_request(const SHELLEXECUTEINFOW* execute) { + check(active != nullptr, "An unscoped shell call was blocked to prevent a UAC prompt"); + check(execute != nullptr && execute->cbSize == sizeof(*execute) && execute->lpVerb != nullptr && + std::wstring_view(execute->lpVerb) == L"runas" && execute->lpFile != nullptr && + steamfix::paths::equal_windows(std::filesystem::path(execute->lpFile), active->self) && + execute->lpParameters != nullptr && + execute->fMask == (SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC), + "The simulated shell request escaped its same-executable boundary"); + std::wstring parsed_command = L"fixture.exe "; + parsed_command += execute->lpParameters; + int count{}; + LPWSTR* raw = CommandLineToArgvW(parsed_command.c_str(), &count); + if (raw == nullptr) steamfix::elevation::fail(GetLastError()); + const steamfix::LocalMemory memory(raw); + if (count != 4 && count != 7) { + throw std::runtime_error("The simulated shell request has invalid private arguments"); + } + const std::span parsed_arguments(raw, static_cast(count)); + std::vector arguments; + arguments.reserve(parsed_arguments.size()); + for (const auto* argument : parsed_arguments) arguments.emplace_back(argument); + const bool helper = std::wstring_view(arguments[1]) == steamfix::elevation::helper_switch; + check(std::wstring_view(arguments[2]) == std::to_wstring(GetCurrentProcessId()), + "The simulated shell request did not authenticate this parent"); + if (helper) { + const std::wstring_view pipe(arguments[3]); + check(count == 4 && pipe.starts_with(L"\\\\.\\pipe\\D2SteamFix-") && pipe.size() <= 100 && + execute->lpDirectory == nullptr && execute->nShow == SW_HIDE, + "The simulated helper shell request is not confined to its private pipe"); + } else { + check(count == 7 && std::wstring_view(arguments[1]) == steamfix::elevation::elevated_launch_switch && + steamfix::paths::equal_windows(std::filesystem::path(arguments[3]), active->directory) && + std::wstring_view(arguments[4]) == relaunch_switch && + std::wstring_view(arguments[5]) == std::to_wstring(child_status) && + steamfix::paths::equal_windows(std::filesystem::path(arguments[6]), active->directory) && + execute->lpDirectory != nullptr && + steamfix::paths::equal_windows(std::filesystem::path(execute->lpDirectory), active->directory) && + execute->nShow == SW_SHOWNORMAL, + "The simulated handoff shell request escaped its fixture directory"); + } + return helper; +} + +BOOL WINAPI shell_execute(SHELLEXECUTEINFOW* execute) { + const bool helper = validate_shell_request(execute); + ++active->shell_calls; + if (active->mode == Mode::cancel) { + SetLastError(ERROR_CANCELLED); + return FALSE; + } + std::wstring command; + steamfix::command_line::append_quoted(command, active->self.native()); + if (active->mode == Mode::exit_before_connection) { + check(helper, "Premature-exit simulation is confined to helper startup"); + steamfix::command_line::append_quoted(command, exit_switch); + } else { + command.push_back(L' '); + command += execute->lpParameters; + } + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + if (!CreateProcessW(active->self.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, + nullptr, execute->lpDirectory, &startup, &information)) return FALSE; + const steamfix::UniqueHandle thread(information.hThread); + steamfix::UniqueHandle process(information.hProcess); + try { + active->children.push_back(std::make_unique(process.get())); + } catch (...) { + TerminateProcess(process.get(), ERROR_CANCELLED); + WaitForSingleObject(process.get(), 10000); + throw; + } + execute->hProcess = process.release(); + return TRUE; +} + +} // namespace elevation_fault + +namespace { + +namespace elevation = steamfix::elevation; + +static_assert(elevation::WireObject); +static_assert(elevation::WireObject); +static_assert(!elevation::WireObject); +static_assert(!elevation::WireObject); +static_assert(std::is_nothrow_copy_constructible_v); +static_assert(std::is_nothrow_copy_assignable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(!std::is_copy_constructible_v); +static_assert(!std::is_move_constructible_v); + +void require(const bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +template +void expect_error(Operation operation, const DWORD expected) { + try { + operation(); + } catch (const std::system_error& error) { + require(error.code().category() == std::system_category(), "Wrong Windows error category"); + require(error.code().value() == steamfix::windows_exit_status(expected), "Wrong Windows error value"); + return; + } + throw std::runtime_error("Expected a Windows error"); +} + +std::wstring unique_name() { + GUID identifier{}; + require(SUCCEEDED(CoCreateGuid(&identifier)), "Cannot generate a fixture identifier"); + std::array text{}; + require(StringFromGUID2(identifier, text.data(), static_cast(text.size())) != 0, + "Cannot format a fixture identifier"); + return text.data(); +} + +class TemporaryDirectory final { +public: + TemporaryDirectory() { + std::array temporary{}; + const DWORD length = GetTempPathW(static_cast(temporary.size()), temporary.data()); + require(length != 0 && length < temporary.size(), "Cannot locate the temporary directory"); + path_ = fs::canonical(fs::path(temporary.data())) / (L"D2SteamFix-elevation-tests-" + unique_name()); + require(fs::create_directory(path_), "Cannot create the temporary fixture directory"); + } + ~TemporaryDirectory() noexcept { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + TemporaryDirectory(const TemporaryDirectory&) = delete; + TemporaryDirectory& operator=(const TemporaryDirectory&) = delete; + TemporaryDirectory(TemporaryDirectory&&) = delete; + TemporaryDirectory& operator=(TemporaryDirectory&&) = delete; + const fs::path& path() const noexcept { return path_; } +private: + fs::path path_; +}; + +void create_file(const fs::path& path, const std::string_view contents = {}) { + std::ofstream output(path, std::ios::binary); + output << contents; + require(output.good(), "Cannot create a fixture file"); +} + +struct PipePair { + steamfix::UniqueHandle server; + steamfix::UniqueHandle client; +}; + +PipePair create_pipe_pair() { + const std::wstring name = L"\\\\.\\pipe\\D2SteamFix-tests-" + unique_name(); + steamfix::UniqueHandle server(CreateNamedPipeW(name.c_str(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, nullptr)); + steamfix::UniqueHandle client(CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr)); + steamfix::UniqueHandle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + OVERLAPPED overlap{}; + overlap.hEvent = event.get(); + // Opening the client first avoids a pending connection during construction. + if (!ConnectNamedPipe(server.get(), &overlap)) { + const DWORD error = GetLastError(); + if (error == ERROR_IO_PENDING) { + DWORD ignored{}; + if (!GetOverlappedResult(server.get(), &overlap, &ignored, TRUE)) elevation::fail(GetLastError()); + } else if (error != ERROR_PIPE_CONNECTED) { + elevation::fail(error); + } + } + return {std::move(server), std::move(client)}; +} + +class TestChild final { +public: + TestChild() noexcept = default; + ~TestChild() noexcept { + if (process_.get() != nullptr && WaitForSingleObject(process_.get(), 3000) == WAIT_TIMEOUT) { + // This process was created solely for this fixture. Bound cleanup + // even when a failed assertion leaves its helper waiting for input. + TerminateProcess(process_.get(), ERROR_CANCELLED); + WaitForSingleObject(process_.get(), 3000); + } + } + TestChild(const TestChild&) = delete; + TestChild& operator=(const TestChild&) = delete; + TestChild(TestChild&&) = delete; + TestChild& operator=(TestChild&&) = delete; + void adopt(const PROCESS_INFORMATION& information) noexcept { + process_.reset(information.hProcess); + thread_.reset(information.hThread); + } + HANDLE process() const noexcept { return process_.get(); } + DWORD wait() const { + const DWORD waited = WaitForSingleObject(process_.get(), 3000); + if (waited != WAIT_OBJECT_0) { + elevation::fail(waited == WAIT_TIMEOUT ? ERROR_TIMEOUT : GetLastError()); + } + DWORD status{}; + if (!GetExitCodeProcess(process_.get(), &status)) elevation::fail(GetLastError()); + return status; + } +private: + steamfix::UniqueHandle process_; + steamfix::UniqueHandle thread_; +}; + +class TestConnection final { +public: + explicit TestConnection(const HANDLE pipe) + : pipe_(pipe), event_(CreateEventW(nullptr, TRUE, FALSE, nullptr)) { + overlap_.hEvent = event_.get(); + if (!ConnectNamedPipe(pipe_, &overlap_)) { + const DWORD error = GetLastError(); + if (error == ERROR_IO_PENDING) pending_ = true; + else if (error != ERROR_PIPE_CONNECTED) elevation::fail(error); + } + } + ~TestConnection() noexcept { + if (pending_) { + CancelIoEx(pipe_, &overlap_); + DWORD ignored{}; + GetOverlappedResult(pipe_, &overlap_, &ignored, TRUE); + } + } + TestConnection(const TestConnection&) = delete; + TestConnection& operator=(const TestConnection&) = delete; + TestConnection(TestConnection&&) = delete; + TestConnection& operator=(TestConnection&&) = delete; + void wait(const HANDLE child) { + if (!pending_) return; + const std::array waits{event_.get(), child}; + // Coverage instrumentation may pause a new child while loading symbols. + const DWORD waited = WaitForMultipleObjects(static_cast(waits.size()), waits.data(), FALSE, 10000); + if (waited != WAIT_OBJECT_0) elevation::fail(waited == WAIT_TIMEOUT ? ERROR_TIMEOUT : ERROR_BROKEN_PIPE); + DWORD ignored{}; + if (!GetOverlappedResult(pipe_, &overlap_, &ignored, FALSE)) elevation::fail(GetLastError()); + pending_ = false; + } +private: + HANDLE pipe_; + steamfix::UniqueHandle event_; + OVERLAPPED overlap_{}; + bool pending_ = false; +}; + +class HelperFixture final { +public: + HelperFixture() { + const std::wstring name = L"\\\\.\\pipe\\D2SteamFix-" + unique_name(); + pipe_.reset(CreateNamedPipeW(name.c_str(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, nullptr)); + if (pipe_.get() == INVALID_HANDLE_VALUE) elevation::fail(GetLastError()); + TestConnection connection(pipe_.get()); + const fs::path executable = elevation::executable_path(); + std::wstring command; + steamfix::command_line::append_quoted(command, executable.native()); + steamfix::command_line::append_quoted(command, elevation::helper_switch); + steamfix::command_line::append_quoted(command, std::to_wstring(GetCurrentProcessId())); + steamfix::command_line::append_quoted(command, name); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + if (!CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, nullptr, &startup, &information)) elevation::fail(GetLastError()); + child_.adopt(information); + connection.wait(child_.process()); + ULONG client{}; + require(GetNamedPipeClientProcessId(pipe_.get(), &client) && client == GetProcessId(child_.process()), + "The fixture pipe connected to an unexpected process"); + } + ~HelperFixture() noexcept { pipe_.reset(); } + HelperFixture(const HelperFixture&) = delete; + HelperFixture& operator=(const HelperFixture&) = delete; + HelperFixture(HelperFixture&&) = delete; + HelperFixture& operator=(HelperFixture&&) = delete; + elevation::Response exchange(elevation::Request request, const fs::path& path = {}, const fs::path& second = {}) { + require(path.native().size() <= elevation::max_path_units && second.native().size() <= elevation::max_path_units, + "Fixture paths exceed the helper protocol limit"); + request.path_units = static_cast(path.native().size()); + request.second_path_units = static_cast(second.native().size()); + send(request); + elevation::transfer(pipe_.get(), std::as_bytes(std::span{path.native()}), 3000); + elevation::transfer(pipe_.get(), std::as_bytes(std::span{second.native()}), 3000); + return elevation::read_object(pipe_.get(), 3000); + } + void send(const elevation::Request& request) { + elevation::transfer(pipe_.get(), std::as_bytes(std::span{&request, 1}), 3000); + } + void send_prefix(const elevation::Request& request) { + const auto bytes = std::as_bytes(std::span{&request, 1}); + elevation::transfer(pipe_.get(), bytes.first(bytes.size() / 2U), 3000); + } + DWORD wait() const { return child_.wait(); } + DWORD disconnect() { + pipe_.reset(); + return child_.wait(); + } +private: + // Destruction closes the pipe before waiting for the exclusively owned child. + TestChild child_; + steamfix::UniqueHandle pipe_; +}; + +void check_protocol_and_transport() { + PipePair pipe = create_pipe_pair(); + elevation::Request request{}; + request.operation = elevation::Operation::open_file; + request.access = GENERIC_READ; + request.share = FILE_SHARE_READ; + request.creation = OPEN_EXISTING; + request.flags = FILE_FLAG_OPEN_REPARSE_POINT; + request.pid = 1234; + request.path_units = 27; + request.second_path_units = 12; + elevation::write_object(pipe.client.get(), request); + const auto received = elevation::read_object(pipe.server.get(), 2000); + require(std::ranges::equal(std::as_bytes(std::span{&request, 1}), + std::as_bytes(std::span{&received, 1})), "Request bytes changed in transit"); + + const elevation::Response response{ERROR_ACCESS_DENIED, 0, std::uintptr_t{0x12345678}}; + elevation::write_object(pipe.server.get(), response); + const auto reply = elevation::read_object(pipe.client.get(), 2000); + require(reply.error == response.error && reply.reserved == 0 && reply.handle == response.handle, + "Response fields changed in transit"); + + const std::wstring path = L"C:\\淇京 馃幃\\steamfix.dat"; + elevation::transfer(pipe.client.get(), std::as_bytes(std::span{path}), 2000); + require(elevation::read_path(pipe.server.get(), static_cast(path.size())) == path, + "The protocol changed a Unicode path"); + require(elevation::read_path(nullptr, 0).empty(), "An empty path was not preserved"); + expect_error([] { static_cast(elevation::read_path(nullptr, elevation::max_path_units + 1)); }, + ERROR_INVALID_DATA); + + std::vector payload(128U * 1024U); + for (std::size_t index = 0; index < payload.size(); ++index) { + payload[index] = static_cast(index % 251U); + } + std::vector result(payload.size()); + std::exception_ptr reader_error; + { + std::jthread reader([&] { + try { + elevation::transfer(pipe.server.get(), std::span{result}, 2000); + } catch (...) { + reader_error = std::current_exception(); + } + }); + elevation::transfer(pipe.client.get(), std::as_bytes(std::span{payload}), 2000); + } + if (reader_error) std::rethrow_exception(reader_error); + require(result == payload, "A transfer larger than the pipe buffer lost bytes"); + + std::array byte{}; + expect_error([&] { elevation::transfer(pipe.server.get(), std::span{byte}, 10); }, ERROR_TIMEOUT); + const std::array sent{std::byte{42}}; + elevation::transfer(pipe.client.get(), std::as_bytes(std::span{sent}), 2000); + elevation::transfer(pipe.server.get(), std::as_writable_bytes(std::span{byte}), 2000); + require(byte == sent, "Cancelled I/O damaged the next exchange"); + elevation::transfer(nullptr, std::span{}, 10); + elevation::transfer(nullptr, std::span{}, 10); + + pipe.client.reset(); + expect_error([&] { elevation::transfer(pipe.server.get(), std::span{byte}, 2000); }, ERROR_BROKEN_PIPE); +} + +void check_paths_and_file_operations() { + const fs::path archive_name(L"C:\\directory\\archive.old"); + const auto rename_bytes = elevation::rename_information(archive_name); + const std::size_t filename_bytes = archive_name.native().size() * sizeof(wchar_t); + require(rename_bytes.size() == offsetof(FILE_RENAME_INFO, FileName) + filename_bytes + sizeof(wchar_t), + "The native rename request has an unexpected byte length"); + require(std::ranges::all_of(std::span{rename_bytes}.first(offsetof(FILE_RENAME_INFO, FileNameLength)), + [](const std::byte byte) { return byte == std::byte{}; }), + "The native rename request contains nonzero replacement/root/reserved bytes"); + FILE_RENAME_INFO native_rename{}; + std::memcpy(&native_rename, rename_bytes.data(), offsetof(FILE_RENAME_INFO, FileName)); + require(native_rename.ReplaceIfExists == FALSE && native_rename.RootDirectory == nullptr && + native_rename.FileNameLength == archive_name.native().size() * sizeof(wchar_t), + "The native rename header does not describe a non-replacing absolute rename"); + require(std::ranges::equal(std::span{rename_bytes}.subspan(offsetof(FILE_RENAME_INFO, FileName), filename_bytes), + std::as_bytes(std::span{archive_name.native()})), + "The native rename filename bytes do not match their declared length"); + require(std::ranges::all_of(std::span{rename_bytes}.last(sizeof(wchar_t)), + [](const std::byte byte) { return byte == std::byte{}; }), + "The native rename filename is missing its trailing Unicode NUL"); + TemporaryDirectory fixture; + const fs::path& directory = fixture.path(); + elevation::validate_path(directory / L"future.dat"); + for (const std::wstring_view invalid : {L"", L"relative.dat", L"C:relative.dat", L"\\\\server\\share\\file", + L"\\\\.\\device", L"C:\\directory\\file:stream"}) { + expect_error([&] { elevation::validate_path(fs::path(invalid)); }, ERROR_BAD_PATHNAME); + } + std::wstring embedded_null = (directory / L"file").native(); + embedded_null.push_back(L'\0'); + embedded_null += L"ignored"; + expect_error([&] { elevation::validate_path(fs::path(embedded_null)); }, ERROR_BAD_PATHNAME); + expect_error([] { + elevation::validate_path(fs::path(L"C:\\" + std::wstring(elevation::max_path_units, L'x'))); + }, ERROR_BAD_PATHNAME); + + require(!elevation::regular(directory) && !elevation::regular(directory / L"missing"), + "A directory or missing path was classified as a regular file"); + create_file(directory / L"steam.exe"); + create_file(directory / L"destiny2.exe"); + create_file(directory / L"destiny2launcher.exe"); + require(elevation::renderer(directory / L"GameOverlayRenderer64.dll") && + elevation::renderer(directory / L"gameoverlayrenderer.dll") && + !elevation::renderer(directory / L"other.dll"), "Renderer classification failed"); + require(elevation::recovery(directory / L"steamfix.dat") && + elevation::recovery(directory / L"steamfix.dat.tmp") && + elevation::recovery(directory / L"steamfix.dat.old") && + !elevation::recovery(directory / L"other.dat"), "Recovery path classification failed"); + require(elevation::game_log(directory / L"STEAMFIX.LOG") && + !elevation::game_log(directory / L"other.log"), "Log path classification failed"); + + const fs::path renderer = directory / L"GameOverlayRenderer64.dll"; + create_file(renderer, "renderer fixture"); + elevation::Request denied{}; + denied.access = WRITE_OWNER; + expect_error([&] { static_cast(elevation::privileged_file(denied, renderer, nullptr)); }, ERROR_ACCESS_DENIED); + denied.access = FILE_WRITE_DATA; + denied.creation = OPEN_EXISTING; + expect_error([&] { static_cast(elevation::privileged_file(denied, renderer, nullptr)); }, ERROR_ACCESS_DENIED); + elevation::Request permitted{}; + permitted.access = FILE_READ_DATA; + permitted.share = FILE_SHARE_READ; + permitted.creation = OPEN_EXISTING; + auto opened = elevation::privileged_file(permitted, renderer, nullptr); + elevation::check_regular_handle(opened.get()); + opened.reset(); + auto normal = elevation::open_file(renderer, FILE_READ_DATA | READ_CONTROL, FILE_SHARE_READ, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL); + PSID owner{}; + PSECURITY_DESCRIPTOR descriptor{}; + const DWORD owner_result = GetSecurityInfo(normal.get(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, + &owner, nullptr, nullptr, nullptr, &descriptor); + if (owner_result != ERROR_SUCCESS) elevation::fail(owner_result); + const steamfix::LocalMemory owner_memory(descriptor); + elevation::check_owner(normal.get(), owner); + std::array world_sid{}; + DWORD sid_bytes = static_cast(world_sid.size()); + require(CreateWellKnownSid(WinWorldSid, nullptr, world_sid.data(), &sid_bytes) != FALSE, + "Cannot create an owner-mismatch fixture SID"); + expect_error([&] { elevation::check_owner(normal.get(), world_sid.data()); }, ERROR_ACCESS_DENIED); + normal.reset(); + expect_error([&] { + static_cast(elevation::checked_file(directory, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS)); + }, ERROR_REPARSE_TAG_INVALID); + + const fs::path hard_link = directory / L"renderer-hard-link.dll"; + if (CreateHardLinkW(hard_link.c_str(), renderer.c_str(), nullptr)) { + expect_error([&] { + static_cast(elevation::checked_file(renderer, FILE_READ_DATA, FILE_SHARE_READ, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT)); + }, ERROR_INVALID_DATA); + } else { + std::cout << "SKIP: the temporary filesystem cannot create a hard-link fixture.\n"; + } + const fs::path directory_link = directory / L"directory-link"; + if (CreateSymbolicLinkW(directory_link.c_str(), directory.c_str(), + SYMBOLIC_LINK_FLAG_DIRECTORY | SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE)) { + expect_error([&] { elevation::validate_path(directory_link / L"future.dat"); }, ERROR_REPARSE_TAG_INVALID); + } else { + std::cout << "SKIP: this account cannot create a symbolic-link fixture.\n"; + } + + const fs::path source = directory / L"steamfix.dat.tmp"; + const fs::path destination = directory / L"steamfix.dat"; + create_file(source, "private fixture"); + elevation::move_recovery_file(source, destination); + require(!fs::exists(source) && fs::exists(destination), "Moving a recovery fixture failed"); + require(elevation::remove_recovery_file(destination) && !elevation::remove_recovery_file(destination), + "Recovery deletion did not distinguish a missing file"); + expect_error([&] { elevation::move_recovery_file(source, destination); }, ERROR_FILE_NOT_FOUND); + + const fs::path log = directory / L"steamfix.log"; + require(!elevation::rotate_log(log), "A missing log was rotated"); + expect_error([&] { static_cast(elevation::rotate_log(directory / L"other.log")); }, ERROR_ACCESS_DENIED); + create_file(log, "small"); + require(!elevation::rotate_log(log), "A small log was rotated"); + { + auto file = elevation::open_file(log, GENERIC_WRITE, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL); + LARGE_INTEGER length{}; + length.QuadPart = 4LL * 1024LL * 1024LL + 1; + require(SetFilePointerEx(file.get(), length, nullptr, FILE_BEGIN) && SetEndOfFile(file.get()), + "Cannot resize the log fixture"); + } + create_file(directory / L"steamfix.log.old", "preserve"); + const bool rotated = elevation::rotate_log(log); + const fs::path expected_archive = directory / L"steamfix.log.old.1"; + if (!rotated || fs::exists(log) || !fs::exists(expected_archive)) { + std::cerr << "Rotation diagnostics: result=" << rotated + << ", native header size=" << sizeof(FILE_RENAME_INFO) + << ", replacement width=" << sizeof(native_rename.ReplaceIfExists) + << ", root offset=" << offsetof(FILE_RENAME_INFO, RootDirectory) + << ", length offset=" << offsetof(FILE_RENAME_INFO, FileNameLength) + << ", filename offset=" << offsetof(FILE_RENAME_INFO, FileName) << '\n'; + for (const auto& entry : fs::directory_iterator(directory)) { + std::error_code error; + const auto size = entry.file_size(error); + std::cerr << "Fixture entry: " << steamfix::display_path(entry.path().filename()) + << ", bytes=" << (error ? 0 : size) << '\n'; + } + const fs::path first_archive = directory / L"steamfix.log.old"; + if (fs::is_regular_file(first_archive)) { + std::ifstream contents(first_archive, std::ios::binary); + std::array prefix{}; + contents.read(prefix.data(), static_cast(prefix.size())); + std::cerr << "First archive prefix after rotation: " + << std::string_view(prefix.data(), static_cast(contents.gcount())) << '\n'; + } + throw std::runtime_error("Rotation did not preserve the occupied archive and create the next archive"); + } + require(fs::file_size(expected_archive) > 4U * 1024U * 1024U, + "Oversized log rotation failed"); + std::ifstream archived(directory / L"steamfix.log.old", std::ios::binary); + const std::string preserved{std::istreambuf_iterator(archived), std::istreambuf_iterator()}; + require(preserved == "preserve", "Rotation replaced an existing archive"); +} + +std::vector token_privileges() { + HANDLE raw{}; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw)) elevation::fail(GetLastError()); + const steamfix::UniqueHandle token(raw); + DWORD needed{}; + GetTokenInformation(token.get(), TokenPrivileges, nullptr, 0, &needed); + require(needed >= offsetof(TOKEN_PRIVILEGES, Privileges), "Invalid token privilege length"); + std::vector storage(needed); + if (!GetTokenInformation(token.get(), TokenPrivileges, storage.data(), needed, &needed)) elevation::fail(GetLastError()); + DWORD count{}; + std::memcpy(&count, storage.data(), sizeof(count)); + const auto bytes = std::span{storage}.subspan(offsetof(TOKEN_PRIVILEGES, Privileges)); + require(count <= bytes.size() / sizeof(LUID_AND_ATTRIBUTES), "Truncated token privileges"); + std::vector privileges(count); + std::ranges::copy(bytes.first(privileges.size() * sizeof(LUID_AND_ATTRIBUTES)), + std::as_writable_bytes(std::span{privileges}).begin()); + return privileges; +} + +void check_privilege_restoration() { + const auto user = elevation::token_user(GetCurrentProcess()); + require(IsValidSid(elevation::user_sid(user)) && elevation::sid_string(elevation::user_sid(user)).starts_with(L"S-"), + "Token-user byte marshalling lost the SID"); + expect_error([] { static_cast(elevation::user_sid({})); }, ERROR_INVALID_DATA); + expect_error([] { const elevation::ScopedPrivilege unknown(L"D2SteamFixUnknownPrivilege"); }, ERROR_NO_SUCH_PRIVILEGE); + + const auto before = token_privileges(); + const auto disabled = std::ranges::find_if(before, [](const auto& privilege) { + return (privilege.Attributes & (SE_PRIVILEGE_ENABLED | SE_PRIVILEGE_REMOVED)) == 0; + }); + if (disabled == before.end()) { + std::cout << "SKIP: token has no disabled privilege for the restoration test.\n"; + return; + } + std::array name{}; + DWORD length = static_cast(name.size()); + LUID identifier = disabled->Luid; + if (!LookupPrivilegeNameW(nullptr, &identifier, name.data(), &length)) elevation::fail(GetLastError()); + const auto enabled = [&] { + const auto current = token_privileges(); + const auto found = std::ranges::find_if(current, [&](const auto& privilege) { + return privilege.Luid.LowPart == identifier.LowPart && privilege.Luid.HighPart == identifier.HighPart; + }); + require(found != current.end(), "The selected token privilege disappeared"); + return (found->Attributes & SE_PRIVILEGE_ENABLED) != 0; + }; + { + const std::lock_guard lock(elevation::privilege_mutex()); + { + const elevation::ScopedPrivilege privilege(name.data()); + require(enabled(), "The scoped privilege was not enabled"); + SetLastError(ERROR_INVALID_DATA); + } + require(GetLastError() == ERROR_INVALID_DATA, "Privilege cleanup replaced the caller's last error"); + } + require(!enabled(), "The scoped privilege was not restored on normal exit"); + struct TestUnwind final {}; + try { + const std::lock_guard lock(elevation::privilege_mutex()); + const elevation::ScopedPrivilege privilege(name.data()); + throw TestUnwind{}; + } catch (const TestUnwind&) { + } + require(!enabled(), "The scoped privilege was not restored during unwinding"); +} + +bool has_token_privilege(const wchar_t* name) { + LUID identifier{}; + if (!LookupPrivilegeValueW(nullptr, name, &identifier)) elevation::fail(GetLastError()); + const auto privileges = token_privileges(); + return std::ranges::any_of(privileges, [&](const auto& privilege) { + return privilege.Luid.LowPart == identifier.LowPart && privilege.Luid.HighPart == identifier.HighPart && + (privilege.Attributes & SE_PRIVILEGE_REMOVED) == 0; + }); +} + +void check_authenticated_helper() { + if (!elevation::is_elevated()) { + std::cout << "SKIP: authenticated helper-loop fixtures require an already elevated test token; no UAC is started.\n"; + return; + } + TemporaryDirectory fixture; + const fs::path& directory = fixture.path(); + create_file(directory / L"steam.exe"); + create_file(directory / L"destiny2.exe"); + create_file(directory / L"destiny2launcher.exe"); + const fs::path renderer = directory / L"GameOverlayRenderer64.dll"; + constexpr std::string_view renderer_contents = "helper renderer fixture"; + create_file(renderer, renderer_contents); + + HelperFixture helper; + elevation::Request request{}; + request.operation = elevation::Operation::open_file; + request.access = WRITE_OWNER; + request.share = FILE_SHARE_READ; + request.creation = OPEN_EXISTING; + auto response = helper.exchange(request, renderer); + require(response.error == ERROR_ACCESS_DENIED && response.reserved == 0 && response.handle == 0, + "The helper granted a forbidden file access mask"); + request.access = FILE_READ_DATA; + request.share = MAXDWORD; + require(helper.exchange(request, renderer).error == ERROR_ACCESS_DENIED, + "The helper accepted unsupported sharing flags"); + request.share = FILE_SHARE_READ; + request.flags = FILE_FLAG_DELETE_ON_CLOSE; + require(helper.exchange(request, renderer).error == ERROR_ACCESS_DENIED, + "The helper accepted a destructive file flag"); + request.flags = 0; + request.creation = CREATE_ALWAYS; + require(helper.exchange(request, renderer).error == ERROR_ACCESS_DENIED, + "The helper accepted renderer replacement"); + request.creation = OPEN_EXISTING; + request.operation = static_cast(MAXDWORD); + require(helper.exchange(request).error == ERROR_INVALID_DATA, "The helper accepted an unknown operation"); + request.operation = elevation::Operation::open_process; + request.pid = 0; + require(helper.exchange(request).error == ERROR_INVALID_PARAMETER, + "The helper accepted an invalid process identifier"); + request.pid = GetCurrentProcessId(); + response = helper.exchange(request); + require(response.error != ERROR_SUCCESS && response.handle == 0, + "The helper granted termination access to the test executable"); + request.operation = elevation::Operation::move_state; + require(helper.exchange(request, renderer, directory / L"other.dat").error == ERROR_ACCESS_DENIED, + "The helper accepted an unrelated recovery rename"); + request.operation = elevation::Operation::rotate_log; + require(helper.exchange(request, directory / L"other.log").error == ERROR_ACCESS_DENIED, + "The helper accepted an unrelated log path"); + + request = elevation::Request{}; + request.operation = elevation::Operation::open_file; + request.access = FILE_READ_DATA; + request.share = FILE_SHARE_READ; + request.creation = OPEN_EXISTING; + response = helper.exchange(request, renderer); + require(response.error == ERROR_SUCCESS && response.reserved == 0 && response.handle != 0, + "The authenticated helper could not open an allowed renderer fixture"); + steamfix::UniqueHandle duplicated(reinterpret_cast(response.handle)); + + if (has_token_privilege(SE_BACKUP_NAME) && has_token_privilege(SE_RESTORE_NAME)) { + const fs::path temporary = directory / L"steamfix.dat.tmp"; + const fs::path recovery = directory / L"steamfix.dat"; + request.access = GENERIC_WRITE; + request.share = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; + request.creation = CREATE_NEW; + response = helper.exchange(request, temporary); + require(response.error == ERROR_SUCCESS && response.handle != 0, + "The helper could not create a private recovery fixture"); + steamfix::UniqueHandle journal(reinterpret_cast(response.handle)); + constexpr std::string_view journal_contents = "private helper recovery fixture"; + DWORD written{}; + require(WriteFile(journal.get(), journal_contents.data(), static_cast(journal_contents.size()), + &written, nullptr) && written == journal_contents.size(), + "The duplicated recovery handle was not writable"); + const auto user = elevation::token_user(GetCurrentProcess()); + elevation::check_owner(journal.get(), elevation::user_sid(user)); + journal.reset(); + request.operation = elevation::Operation::move_state; + response = helper.exchange(request, temporary, recovery); + require(response.error == ERROR_SUCCESS && response.handle == 0 && + !fs::exists(temporary) && fs::exists(recovery), "The helper could not rename its private recovery file"); + + const fs::path log = directory / L"steamfix.log"; + create_file(log); + { + auto file = elevation::open_file(log, GENERIC_WRITE, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL); + LARGE_INTEGER length{}; + length.QuadPart = 4LL * 1024LL * 1024LL + 1; + require(SetFilePointerEx(file.get(), length, nullptr, FILE_BEGIN) && SetEndOfFile(file.get()), + "Cannot resize the helper log fixture"); + } + create_file(directory / L"steamfix.log.old", "preserve helper archive"); + request.operation = elevation::Operation::rotate_log; + response = helper.exchange(request, log); + require(response.error == ERROR_SUCCESS && response.handle == 1 && !fs::exists(log) && + fs::file_size(directory / L"steamfix.log.old.1") > 4U * 1024U * 1024U, + "The helper did not archive its oversized log fixture"); + response = helper.exchange(request, log); + require(response.error == ERROR_SUCCESS && response.handle == 0, + "The helper did not defer rotation of a missing log"); + } else { + std::cout << "SKIP: the elevated token lacks backup/restore privileges for private recovery and log fixtures.\n"; + } + + require(helper.disconnect() == ERROR_SUCCESS, "A helper did not exit cleanly after its pipe disconnected"); + // The transferred handle belongs to this process and remains usable after + // the helper, including its original source handle, has exited. + std::array data{}; + DWORD read{}; + require(ReadFile(duplicated.get(), data.data(), static_cast(data.size()), &read, nullptr) && + std::string_view(data.data(), read) == renderer_contents, + "A duplicated renderer handle did not survive helper exit"); + duplicated.reset(); + + { + HelperFixture malformed; + elevation::Request invalid{}; + invalid.magic = 0; + malformed.send(invalid); + require(malformed.wait() == ERROR_INVALID_DATA, "The helper accepted invalid protocol magic"); + } + { + HelperFixture malformed; + elevation::Request invalid{}; + invalid.path_units = elevation::max_path_units + 1; + malformed.send(invalid); + require(malformed.wait() == ERROR_INVALID_DATA, "The helper accepted an oversized path frame"); + } + { + HelperFixture truncated; + const elevation::Request partial{}; + truncated.send_prefix(partial); + require(truncated.disconnect() == ERROR_SUCCESS, "A truncated request leaked its waiting helper process"); + } +} + +void check_broker_and_relaunch_boundaries() { + if (!elevation_fault::real_elevated()) { + std::cout << "SKIP: broker/relaunch shell-boundary fixtures require an already elevated token; no UAC is started.\n"; + return; + } + TemporaryDirectory fixture; + const fs::path& directory = fixture.path(); + create_file(directory / L"steam.exe"); + const fs::path renderer = directory / L"GameOverlayRenderer64.dll"; + constexpr std::string_view contents = "broker renderer fixture"; + create_file(renderer, contents); + const fs::path self = elevation::executable_path(); + + elevation_fault::Plan broker_plan; + broker_plan.self = self; + broker_plan.directory = directory; + broker_plan.mode = elevation_fault::Mode::cancel; + { + const elevation_fault::Scope boundary(broker_plan); + { + elevation::Broker broker; + expect_error([&] { broker.ensure_started(); }, ERROR_CANCELLED); + require(broker_plan.shell_calls == 1 && broker_plan.children.empty(), + "A cancelled shell request started a child"); + broker_plan.mode = elevation_fault::Mode::launch; + broker.ensure_started(); + broker.ensure_started(); + require(broker_plan.shell_calls == 2 && broker_plan.children.size() == 1, + "A healthy broker did not reuse its authenticated helper"); + elevation::Request request{}; + request.operation = static_cast(MAXDWORD); + expect_error([&] { static_cast(broker.request(request)); }, ERROR_INVALID_DATA); + request.operation = elevation::Operation::open_file; + request.access = FILE_READ_DATA; + request.share = FILE_SHARE_READ; + request.creation = OPEN_EXISTING; + expect_error([&] { + static_cast(broker.request(request, fs::path(L"C:\\" + std::wstring(elevation::max_path_units, L'x')))); + }, ERROR_BAD_PATHNAME); + const auto response = broker.request(request, renderer); + require(response.handle != 0 && broker_plan.shell_calls == 2, + "An application error incorrectly dropped the broker connection"); + steamfix::UniqueHandle transferred(reinterpret_cast(response.handle)); + std::array bytes{}; + DWORD read{}; + require(ReadFile(transferred.get(), bytes.data(), static_cast(bytes.size()), &read, nullptr) && + std::string_view(bytes.data(), read) == contents, + "A real broker response did not transfer a readable fixture handle"); + transferred.reset(); + + broker_plan.children.front()->terminate(); + bool transport_failed = false; + try { + static_cast(broker.request(request, renderer)); + } catch (const std::system_error& error) { + const DWORD code = static_cast(error.code().value()); + require(error.code().category() == std::system_category() && + (code == ERROR_BROKEN_PIPE || code == ERROR_NO_DATA || code == ERROR_PIPE_NOT_CONNECTED), + "The terminated helper did not produce a pipe transport failure"); + transport_failed = true; + } + require(transport_failed, "A broker exchange succeeded after its owned helper exited"); + const auto retried = broker.request(request, renderer); + require(retried.handle != 0 && broker_plan.shell_calls == 3 && broker_plan.children.size() == 2, + "A failed transport did not start a fresh authenticated helper"); + const steamfix::UniqueHandle retry_handle(reinterpret_cast(retried.handle)); + } + require(broker_plan.children.back()->wait() == ERROR_SUCCESS, + "Destroying the local broker left its helper waiting on the pipe"); + } + + elevation_fault::Plan early_exit; + early_exit.self = self; + early_exit.directory = directory; + early_exit.mode = elevation_fault::Mode::exit_before_connection; + { + const elevation_fault::Scope boundary(early_exit); + elevation::Broker broker; + expect_error([&] { broker.ensure_started(); }, ERROR_BROKEN_PIPE); + require(early_exit.children.size() == 1 && early_exit.children.front()->wait() == elevation_fault::child_status, + "The broker did not observe its owned helper exit before connecting"); + } + + elevation_fault::Plan relaunch; + relaunch.self = self; + relaunch.directory = directory; + relaunch.report_unelevated = true; + const std::vector forwarded{std::wstring(elevation_fault::relaunch_switch), + std::to_wstring(elevation_fault::child_status), directory.native()}; + { + const elevation_fault::Scope boundary(relaunch); + const auto result = elevation::relaunch_as_administrator(forwarded, directory, directory); + require(result == steamfix::windows_exit_status(elevation_fault::child_status) && + relaunch.shell_calls == 1 && relaunch.children.size() == 1 && + relaunch.children.front()->wait() == elevation_fault::child_status, + "The private relaunch did not authenticate its child, forward context, and preserve exit status"); + } + require(elevation::is_elevated(), "A scoped elevation-report simulation escaped into later tests"); +} + +void check_elevated_permission_fallbacks() { + if (!elevation_fault::real_elevated()) { + std::cout << "SKIP: permission-fallback fixtures require an already elevated token; no UAC is started.\n"; + return; + } + TemporaryDirectory fixture; + const fs::path& directory = fixture.path(); + const fs::path self = elevation::executable_path(); + const fs::path destiny = directory / L"destiny2.exe"; + const fs::path steam = directory / L"steamwebhelper.exe"; + fs::copy_file(self, destiny); + fs::copy_file(self, steam); + create_file(directory / L"destiny2launcher.exe"); + create_file(directory / L"steam.exe"); + const fs::path renderer = directory / L"GameOverlayRenderer64.dll"; + constexpr std::string_view contents = "permission fallback renderer"; + create_file(renderer, contents); + + elevation_fault::Plan plan; + plan.self = self; + plan.directory = directory; + const auto start_suspended = [&](const fs::path& image) { + std::wstring command; + steamfix::command_line::append_quoted(command, image.native()); + steamfix::command_line::append_quoted(command, elevation_fault::exit_switch); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + if (!CreateProcessW(image.c_str(), command.data(), nullptr, nullptr, FALSE, + CREATE_SUSPENDED | CREATE_NO_WINDOW, nullptr, directory.c_str(), &startup, &information)) { + elevation::fail(GetLastError()); + } + const steamfix::UniqueHandle thread(information.hThread); + steamfix::UniqueHandle process(information.hProcess); + try { + plan.children.push_back(std::make_unique(process.get())); + } catch (...) { + TerminateProcess(process.get(), ERROR_CANCELLED); + WaitForSingleObject(process.get(), 10000); + throw; + } + return process; + }; + // Copies never execute: suspended children expose only test-owned process + // images to the production allowlist and remain guarded by retained handles. + const auto destiny_child = start_suspended(destiny); + const auto steam_child = start_suspended(steam); + const DWORD destiny_pid = GetProcessId(destiny_child.get()); + const DWORD steam_pid = GetProcessId(steam_child.get()); + require(destiny_pid != 0 && steam_pid != 0, "Cannot identify the suspended fixture processes"); + { + const elevation_fault::Scope boundary(plan); + plan.denied_file = renderer; + plan.file_denials = 1; + auto file = elevation::open_file(renderer, FILE_READ_DATA, FILE_SHARE_READ, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL); + std::array bytes{}; + DWORD read{}; + require(plan.file_denials == 0 && + ReadFile(file.get(), bytes.data(), static_cast(bytes.size()), &read, nullptr) && + std::string_view(bytes.data(), read) == contents, + "The public file opener did not recover from its initial permission denial"); + + plan.denied_pid = destiny_pid; + plan.process_denials = 1; + auto process = elevation::open_process_for_termination(destiny_pid); + require(plan.process_denials == 0 && GetProcessId(process.get()) == destiny_pid && + WaitForSingleObject(process.get(), 0) == WAIT_TIMEOUT && plan.shell_calls == 0, + "The public process opener did not use its already elevated allowlisted fallback"); + + HelperFixture helper; + elevation::Request request{}; + request.operation = elevation::Operation::open_process; + request.pid = destiny_pid; + const auto game_response = helper.exchange(request); + require(game_response.error == ERROR_SUCCESS && game_response.handle != 0, + "The authenticated helper rejected its owned game-process fixture"); + const steamfix::UniqueHandle game_handle(reinterpret_cast(game_response.handle)); + request.pid = steam_pid; + const auto steam_response = helper.exchange(request); + require(steam_response.error == ERROR_SUCCESS && steam_response.handle != 0, + "The authenticated helper rejected its owned Steam-process fixture"); + const steamfix::UniqueHandle steam_handle(reinterpret_cast(steam_response.handle)); + require(helper.disconnect() == ERROR_SUCCESS && GetProcessId(game_handle.get()) == destiny_pid && + GetProcessId(steam_handle.get()) == steam_pid && + WaitForSingleObject(game_handle.get(), 0) == WAIT_TIMEOUT && + WaitForSingleObject(steam_handle.get(), 0) == WAIT_TIMEOUT, + "Transferred process handles lost their identity or synchronization rights after helper exit"); + require(TerminateProcess(process.get(), ERROR_CANCELLED) && + TerminateProcess(steam_handle.get(), ERROR_CANCELLED) && + plan.children[0]->wait() == ERROR_CANCELLED && plan.children[1]->wait() == ERROR_CANCELLED, + "The public or helper process grant lacks termination rights for its owned fixture"); + } +} + +void check_errors_and_entry_points() { + require(elevation::permission_denied(ERROR_ACCESS_DENIED) && + elevation::permission_denied(ERROR_PRIVILEGE_NOT_HELD) && + !elevation::permission_denied(ERROR_FILE_NOT_FOUND), "Permission-error classification failed"); + for (const DWORD error : {ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION}) { + require(elevation::rotation_deferred(error), "A deferred rotation error was not recognized"); + } + require(!elevation::rotation_deferred(ERROR_ACCESS_DENIED), "A permission error deferred rotation"); + try { + elevation::fail(ERROR_CANCELLED); + } catch (const elevation::Cancelled& error) { + require(error.code().value() == ERROR_CANCELLED, "Cancellation lost its Windows error"); + } + expect_error([] { elevation::fail(ERROR_FILE_NOT_FOUND); }, ERROR_FILE_NOT_FOUND); + require(!elevation::dispatch_helper({}) && !elevation::dispatch_helper({L"ordinary"}), + "Ordinary arguments invoked the private helper"); + require(elevation::dispatch_helper({std::wstring(elevation::helper_switch)}) == ERROR_INVALID_PARAMETER, + "The helper accepted missing parameters"); + require(elevation::dispatch_helper({std::wstring(elevation::helper_switch), L"4294967296", L"invalid"}) == + ERROR_INVALID_PARAMETER, "The helper narrowed an out-of-range process identifier"); + require(elevation::dispatch_helper({std::wstring(elevation::helper_switch), L"1extra", L"invalid"}) == + ERROR_INVALID_PARAMETER, "The helper accepted trailing process-identifier text"); + require(elevation::dispatch_helper({std::wstring(elevation::helper_switch), L"not-a-number", L"invalid"}) == + ERROR_GEN_FAILURE, "Malformed helper input escaped its exception boundary"); + require(elevation::helper(0, L"invalid") == ERROR_ACCESS_DENIED, "The helper accepted an invalid parent and pipe"); + std::vector arguments{L"ordinary", L"argument"}; + const auto original = arguments; + require(!elevation::accept_elevated_launch(arguments) && arguments == original, + "An ordinary launch changed its arguments or inherited a Steam directory"); + std::vector missing{std::wstring(elevation::elevated_launch_switch)}; + expect_error([&] { static_cast(elevation::accept_elevated_launch(missing)); }, ERROR_INVALID_PARAMETER); + std::vector self{std::wstring(elevation::elevated_launch_switch), + std::to_wstring(GetCurrentProcessId()), L"unused", L"argument"}; + expect_error([&] { static_cast(elevation::accept_elevated_launch(self)); }, ERROR_INVALID_PARAMETER); + expect_error([] { static_cast(elevation::relaunch_as_administrator({}, {}, {})); }, ERROR_INVALID_PARAMETER); + auto process = elevation::open_process_for_termination(GetCurrentProcessId()); + require(GetProcessId(process.get()) == GetCurrentProcessId(), "Opening the current process selected another process"); + + elevation::RelaunchRequired original_error(ERROR_ACCESS_DENIED, L"directory", L"launcher", L"steam"); + elevation::RelaunchRequired copied(original_error); + elevation::RelaunchRequired moved(std::move(original_error)); + copied = std::move(moved); + for (const auto* error : {&original_error, &copied, &moved}) { + require(error->directory() == L"directory" && error->launcher() == L"launcher" && + error->steam_directory() == L"steam", "Exception copying or moving invalidated its payload"); + } +} + +} // namespace + +int main() { + try { + // The subprocess exercises the production helper entry before running + // any fixtures; otherwise it would recursively start another test run. + struct ArgumentsDeleter { + void operator()(wchar_t** arguments) const noexcept { LocalFree(arguments); } + }; + int count{}; + const std::unique_ptr parsed(CommandLineToArgvW(GetCommandLineW(), &count)); + if (!parsed || count <= 0) throw std::runtime_error("Cannot parse the fixture command line"); + const std::span parsed_arguments{parsed.get(), static_cast(count)}; + std::vector arguments; + for (const auto* argument : parsed_arguments.subspan(1)) { + arguments.emplace_back(argument); + } + if (const auto status = elevation::dispatch_helper(arguments)) return *status; + if (!arguments.empty() && arguments.front() == elevation_fault::exit_switch) { + require(arguments.size() == 1, "A premature-exit fixture received unexpected arguments"); + return steamfix::windows_exit_status(elevation_fault::child_status); + } + if (!arguments.empty() && arguments.front() == elevation::elevated_launch_switch) { + const auto inherited = elevation::accept_elevated_launch(arguments); + require(inherited && arguments.size() == 3 && arguments[0] == elevation_fault::relaunch_switch && + arguments[1] == std::to_wstring(elevation_fault::child_status) && + steamfix::paths::equal_windows(*inherited, fs::canonical(fs::path(arguments[2]))), + "The elevated handoff changed its authenticated directory or forwarded arguments"); + for (const auto* name : {L"SteamAppId", L"SteamGameId"}) { + std::array value{}; + require(GetEnvironmentVariableW(name, value.data(), static_cast(value.size())) == 7 && + std::wstring_view(value.data()) == L"1085660", + "The elevated handoff did not establish the child's Steam context"); + } + return steamfix::windows_exit_status(elevation_fault::child_status); + } + check_protocol_and_transport(); + check_paths_and_file_operations(); + check_privilege_restoration(); + check_errors_and_entry_points(); + check_authenticated_helper(); + check_broker_and_relaunch_boundaries(); + check_elevated_permission_fallbacks(); + } catch (const std::exception& error) { + std::cerr << "Elevation regression test failed: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/tests/overlay_fixture.cpp b/tests/overlay_fixture.cpp new file mode 100644 index 0000000..56103fe --- /dev/null +++ b/tests/overlay_fixture.cpp @@ -0,0 +1,4 @@ +// A test-owned DLL used only to exercise real module enumeration. +extern "C" __declspec(dllexport) int steamfix_overlay_fixture() noexcept { + return 1085660; +} diff --git a/tests/process_fault_tests.cpp b/tests/process_fault_tests.cpp new file mode 100644 index 0000000..7963ae9 --- /dev/null +++ b/tests/process_fault_tests.cpp @@ -0,0 +1,406 @@ +// Load every production dependency before rewriting only the API call sites in +// process.cpp. Windows declarations and the separately compiled dependencies +// always retain their real implementations. +#include "steamfix/process.hpp" +#include "steamfix/logging.hpp" +#include "steamfix/paths.hpp" +#include "steamfix/command_line.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace process_fault { + +struct FaultPlan { + HANDLE child = nullptr; + HANDLE game = nullptr; + DWORD child_pid = 0; + DWORD candidate_pid = 0; + int snapshot_creation_remaining = 0; + int first_entry_remaining = 0; + int next_entry_remaining = 0; + int child_wait_remaining = 0; + int candidate_open_remaining = 0; + int exit_status_remaining = 0; + int snapshot_creation_failures = 0; + int first_entry_failures = 0; + int next_entry_failures = 0; + int child_wait_failures = 0; + int candidate_open_failures = 0; + int exit_status_failures = 0; + int real_candidate_opens = 0; + bool snapshot_contains_owned_process = false; + bool snapshot_completed = false; + bool game_confirmed_this_poll = false; + bool reliable_absence_completed = false; + bool post_exit_failure_fired = false; + int post_failure_absent_snapshots = 0; + std::chrono::steady_clock::time_point post_exit_failure_at{}; + std::atomic fail_snapshot_after_exit{false}; + steamfix::UniqueHandle candidate_verified{CreateEventW(nullptr, TRUE, FALSE, nullptr)}; +}; + +// Only the monitor thread reads these finite budgets and counters. The helper +// thread communicates through the atomic flag and native event alone. +thread_local FaultPlan* active_plan = nullptr; + +bool consume(int& budget) noexcept { + if (budget == 0) return false; + --budget; + return true; +} + +HANDLE WINAPI create_snapshot(const DWORD flags, const DWORD pid) { + if (active_plan != nullptr) { + active_plan->snapshot_contains_owned_process = false; + active_plan->snapshot_completed = false; + if (active_plan->fail_snapshot_after_exit.load() && active_plan->reliable_absence_completed && + ::WaitForSingleObject(active_plan->child, 0) == WAIT_OBJECT_0 && + ::WaitForSingleObject(active_plan->game, 0) == WAIT_OBJECT_0 && + active_plan->fail_snapshot_after_exit.exchange(false)) { + ++active_plan->snapshot_creation_failures; + active_plan->post_exit_failure_fired = true; + active_plan->post_exit_failure_at = std::chrono::steady_clock::now(); + SetLastError(ERROR_NOT_ENOUGH_MEMORY); + return INVALID_HANDLE_VALUE; + } + if (consume(active_plan->snapshot_creation_remaining)) { + ++active_plan->snapshot_creation_failures; + SetLastError(ERROR_NOT_ENOUGH_MEMORY); + return INVALID_HANDLE_VALUE; + } + } + return ::CreateToolhelp32Snapshot(flags, pid); +} + +BOOL WINAPI first_entry(const HANDLE snapshot, const LPPROCESSENTRY32W entry) { + if (active_plan != nullptr && consume(active_plan->first_entry_remaining)) { + ++active_plan->first_entry_failures; + SetLastError(ERROR_BAD_LENGTH); + return FALSE; + } + const BOOL result = ::Process32FirstW(snapshot, entry); + if (result && active_plan != nullptr && + (entry->th32ProcessID == active_plan->child_pid || entry->th32ProcessID == active_plan->candidate_pid)) { + active_plan->snapshot_contains_owned_process = true; + } + return result; +} + +BOOL WINAPI next_entry(const HANDLE snapshot, const LPPROCESSENTRY32W entry) { + if (active_plan != nullptr && consume(active_plan->next_entry_remaining)) { + ++active_plan->next_entry_failures; + SetLastError(ERROR_BAD_LENGTH); + return FALSE; + } + const BOOL result = ::Process32NextW(snapshot, entry); + const DWORD error = GetLastError(); + if (active_plan != nullptr) { + if (result && (entry->th32ProcessID == active_plan->child_pid || + entry->th32ProcessID == active_plan->candidate_pid)) { + active_plan->snapshot_contains_owned_process = true; + } + if (!result && error == ERROR_NO_MORE_FILES) { + active_plan->snapshot_completed = true; + if (active_plan->post_exit_failure_fired && !active_plan->snapshot_contains_owned_process && + ::WaitForSingleObject(active_plan->child, 0) == WAIT_OBJECT_0 && + ::WaitForSingleObject(active_plan->game, 0) == WAIT_OBJECT_0) { + ++active_plan->post_failure_absent_snapshots; + } + } + } + SetLastError(error); + return result; +} + +DWORD WINAPI wait_for_child(const HANDLE handle, const DWORD timeout) { + if (active_plan != nullptr && handle == active_plan->child && + consume(active_plan->child_wait_remaining)) { + ++active_plan->child_wait_failures; + SetLastError(ERROR_INVALID_HANDLE); + return WAIT_FAILED; + } + return ::WaitForSingleObject(handle, timeout); +} + +HANDLE WINAPI open_candidate(const DWORD access, const BOOL inherit, const DWORD pid) { + if (active_plan != nullptr && pid == active_plan->candidate_pid && + consume(active_plan->candidate_open_remaining)) { + ++active_plan->candidate_open_failures; + SetLastError(ERROR_ACCESS_DENIED); + return nullptr; + } + const HANDLE handle = ::OpenProcess(access, inherit, pid); + if (active_plan != nullptr && pid == active_plan->candidate_pid && handle != nullptr) { + ++active_plan->real_candidate_opens; + } + return handle; +} + +BOOL WINAPI exit_code(const HANDLE handle, const LPDWORD status) { + if (active_plan != nullptr && handle == active_plan->child && + ::WaitForSingleObject(handle, 0) == WAIT_OBJECT_0 && + consume(active_plan->exit_status_remaining)) { + ++active_plan->exit_status_failures; + SetLastError(ERROR_ACCESS_DENIED); + return FALSE; + } + return ::GetExitCodeProcess(handle, status); +} + +BOOL WINAPI process_times(const HANDLE handle, const LPFILETIME created, const LPFILETIME exited, + const LPFILETIME kernel, const LPFILETIME user) { + const BOOL result = ::GetProcessTimes(handle, created, exited, kernel, user); + const DWORD error = GetLastError(); + if (result && active_plan != nullptr && ::GetProcessId(handle) == active_plan->candidate_pid) { + // Production reaches creation_key only after the real image path was + // verified inside this scenario's unique directory. + active_plan->game_confirmed_this_poll = true; + } + SetLastError(error); + return result; +} + +void WINAPI end_poll(const DWORD milliseconds) { + if (active_plan != nullptr) { + if (active_plan->game_confirmed_this_poll) { + active_plan->game_confirmed_this_poll = false; + SetEvent(active_plan->candidate_verified.get()); + } + if (active_plan->snapshot_completed && !active_plan->snapshot_contains_owned_process && + ::WaitForSingleObject(active_plan->child, 0) == WAIT_OBJECT_0 && + ::WaitForSingleObject(active_plan->game, 0) == WAIT_OBJECT_0) { + // This boundary follows ReleaseState::observe. Inject the next + // failure after one actual all-absent poll has been accumulated. + active_plan->reliable_absence_completed = true; + } + } + ::Sleep(milliseconds); +} + +class Scope final { +public: + explicit Scope(FaultPlan& plan) noexcept : previous_(active_plan) { active_plan = &plan; } + ~Scope() noexcept { active_plan = previous_; } + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + Scope(Scope&&) = delete; + Scope& operator=(Scope&&) = delete; +private: + FaultPlan* previous_; +}; + +} // namespace process_fault + +#define CreateToolhelp32Snapshot process_fault::create_snapshot +#define Process32FirstW process_fault::first_entry +#define Process32NextW process_fault::next_entry +#define WaitForSingleObject process_fault::wait_for_child +#define OpenProcess process_fault::open_candidate +#define GetExitCodeProcess process_fault::exit_code +#define GetProcessTimes process_fault::process_times +#define Sleep process_fault::end_poll +#include "../src/process.cpp" +#undef CreateToolhelp32Snapshot +#undef Process32FirstW +#undef Process32NextW +#undef WaitForSingleObject +#undef OpenProcess +#undef GetExitCodeProcess +#undef GetProcessTimes +#undef Sleep + +namespace { + +void require(const bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +class TemporaryDirectory final { +public: + TemporaryDirectory() { + for (unsigned attempt = 0; attempt < 100; ++attempt) { + path_ = fs::temp_directory_path() / + (L"D2SteamFix-process-faults-" + std::to_wstring(GetCurrentProcessId()) + L"-" + + std::to_wstring(GetTickCount64()) + L"-" + std::to_wstring(attempt)); + if (fs::create_directory(path_)) return; + } + throw std::runtime_error("could not create an isolated process test directory"); + } + ~TemporaryDirectory() noexcept { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + TemporaryDirectory(const TemporaryDirectory&) = delete; + TemporaryDirectory& operator=(const TemporaryDirectory&) = delete; + TemporaryDirectory(TemporaryDirectory&&) = delete; + TemporaryDirectory& operator=(TemporaryDirectory&&) = delete; + const fs::path& path() const noexcept { return path_; } +private: + fs::path path_; +}; + +class OwnedChild final { +public: + explicit OwnedChild(const fs::path& executable) { + std::wstring line; + steamfix::command_line::append_quoted(line, executable.native()); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + if (!CreateProcessW(executable.c_str(), line.data(), nullptr, nullptr, FALSE, + CREATE_SUSPENDED | CREATE_NO_WINDOW, nullptr, + executable.parent_path().c_str(), &startup, &information)) { + throw std::system_error(steamfix::windows_exit_status(GetLastError()), std::system_category()); + } + process_.reset(information.hProcess); + thread_.reset(information.hThread); + pid_ = information.dwProcessId; + } + ~OwnedChild() noexcept { static_cast(finish(0)); } + OwnedChild(const OwnedChild&) = delete; + OwnedChild& operator=(const OwnedChild&) = delete; + OwnedChild(OwnedChild&&) = delete; + OwnedChild& operator=(OwnedChild&&) = delete; + HANDLE get() const noexcept { return process_.get(); } + DWORD pid() const noexcept { return pid_; } + bool finish(const DWORD status) const noexcept { + // Termination is restricted to this fixture's exact CreateProcess handle. + if (::WaitForSingleObject(process_.get(), 0) == WAIT_OBJECT_0) return true; + if (!TerminateProcess(process_.get(), status)) return false; + return ::WaitForSingleObject(process_.get(), 5000) == WAIT_OBJECT_0; + } +private: + steamfix::UniqueHandle process_; + steamfix::UniqueHandle thread_; + DWORD pid_ = 0; +}; + +std::string read_log(const fs::path& path) { + std::ifstream file(path, std::ios::binary); + require(file.is_open(), "could not inspect process test log"); + const std::string result{std::istreambuf_iterator{file}, std::istreambuf_iterator{}}; + require(!file.bad(), "process test log read failed"); + return result; +} + +void require_contains(const std::string& log, const char* message) { + require(log.find(message) != std::string::npos, message); +} + +void run_scenario(const bool unavailable_exit_status) { + TemporaryDirectory directory; + const fs::path self = steamfix::process::image_path(GetCurrentProcess()); + const fs::path launcher_path = directory.path() / L"destiny2launcher.exe"; + const fs::path game_path = directory.path() / L"destiny2.exe"; + require(fs::copy_file(self, launcher_path), "could not copy the launcher fixture"); + require(fs::copy_file(self, game_path), "could not copy the game fixture"); + OwnedChild launcher(launcher_path); + OwnedChild game(game_path); + process_fault::FaultPlan plan; + plan.child = launcher.get(); + plan.game = game.get(); + plan.child_pid = launcher.pid(); + plan.candidate_pid = game.pid(); + if (unavailable_exit_status) { + plan.exit_status_remaining = 1; + } else { + plan.snapshot_creation_remaining = 1; + plan.first_entry_remaining = 1; + plan.next_entry_remaining = 1; + plan.child_wait_remaining = 1; + plan.candidate_open_remaining = 1; + } + std::atomic worker_ok{true}; + constexpr DWORD launcher_status = 0x1234; + const fs::path log_path = directory.path() / L"steamfix.log"; + std::optional result; + bool children_exited_when_monitor_returned = false; + std::chrono::steady_clock::time_point completed_at{}; + { + const steamfix::Logger log(log_path); + // The helper always finishes both owned children after a bounded wait, + // including if a regression prevents their successful observation. + std::jthread finisher([&] { + if (::WaitForSingleObject(plan.candidate_verified.get(), 15000) != WAIT_OBJECT_0) { + worker_ok.store(false); + } + if (!unavailable_exit_status) plan.fail_snapshot_after_exit.store(true); + if (!launcher.finish(launcher_status)) worker_ok.store(false); + if (!game.finish(0)) worker_ok.store(false); + }); + const process_fault::Scope faults(plan); + result = steamfix::process::monitor(launcher.get(), launcher.pid(), directory.path(), log); + completed_at = std::chrono::steady_clock::now(); + children_exited_when_monitor_returned = + ::WaitForSingleObject(launcher.get(), 0) == WAIT_OBJECT_0 && + ::WaitForSingleObject(game.get(), 0) == WAIT_OBJECT_0; + finisher.join(); + } + require(worker_ok.load(), "test-owned children were not observed and finished within their bounds"); + require(plan.real_candidate_opens > 0, "monitor never queried the actual owned game process"); + require(children_exited_when_monitor_returned, + "monitor released protection while an owned process remained alive"); + const std::string log = read_log(log_path); + require_contains(log, "observed process start: destiny2.exe"); + require_contains(log, "observed process exit: destiny2.exe"); + require_contains(log, "both Destiny process categories closed"); + if (unavailable_exit_status) { + require(plan.exit_status_failures == 1 && plan.exit_status_remaining == 0, + "the finite exit-status fault did not fire"); + require(!result, "unavailable launcher status was invented"); + require_contains(log, "original launcher exited; exit status is unavailable"); + } else { + require(result == static_cast(launcher_status), "recovered launcher status changed"); + require(plan.snapshot_creation_failures == 2 && plan.first_entry_failures == 1 && + plan.next_entry_failures == 1 && plan.child_wait_failures == 1 && + plan.candidate_open_failures == 1, + "one or more finite observation faults did not fire"); + require(plan.snapshot_creation_remaining == 0 && plan.first_entry_remaining == 0 && + plan.next_entry_remaining == 0 && plan.child_wait_remaining == 0 && + plan.candidate_open_remaining == 0, "an observation fault remained permanently armed"); + require(plan.post_exit_failure_fired, "post-exit observation failure did not fire"); + require(plan.post_failure_absent_snapshots >= 2, + "unreliable observation reused a negative poll from before the failure"); + require(completed_at - plan.post_exit_failure_at >= std::chrono::milliseconds(800), + "unreliable post-exit observation did not reset two-poll release debounce"); + require_contains(log, "original launcher handle unavailable for 1 consecutive polls"); + require_contains(log, "WaitForSingleObject failed"); + require_contains(log, "original launcher handle became queryable again"); + require_contains(log, "process observation unavailable for 1 consecutive polls"); + require_contains(log, "a named Destiny process candidate could not be verified for 1 consecutive polls"); + require_contains(log, "named Destiny process candidates are verifiable again"); + } +} + +} // namespace + +int main() { + try { + run_scenario(false); + run_scenario(true); + std::cout << "Real process monitoring recovered from finite observation faults.\n"; + } catch (const std::exception& error) { + std::cerr << "Process fault regression failed: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/tests/repair_workflow_tests.cpp b/tests/repair_workflow_tests.cpp new file mode 100644 index 0000000..ab8fd9e --- /dev/null +++ b/tests/repair_workflow_tests.cpp @@ -0,0 +1,677 @@ +// Load native declarations first, then substitute only repair.cpp's external +// boundaries. File permissions, process identity, waits and termination remain +// real Windows operations on this test's own files and child processes. +#include "steamfix/repair.hpp" +#include "steamfix/command_line.hpp" +#include "steamfix/elevation.hpp" +#include "steamfix/error.hpp" +#include "steamfix/logging.hpp" +#include "steamfix/paths.hpp" +#include "steamfix/process.hpp" +#include "steamfix/steam.hpp" +#include "steamfix/ui.hpp" +#include "steamfix/win32.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace repair_workflow { + +namespace fs = std::filesystem; +using steamfix::UniqueHandle; + +void require(const bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +[[noreturn]] void fail(const DWORD error) { + throw std::system_error(steamfix::windows_exit_status(error), std::system_category()); +} + +void write_file(const fs::path& path, const std::string_view contents) { + std::ofstream stream(path, std::ios::binary | std::ios::trunc); + stream << contents; + stream.close(); + require(!stream.fail(), "write repair fixture file"); +} + +std::string read_file(const fs::path& path) { + std::ifstream stream(path, std::ios::binary); + require(static_cast(stream), "read repair fixture file"); + return {std::istreambuf_iterator{stream}, std::istreambuf_iterator{}}; +} + +fs::path executable_path() { + return steamfix::process::image_path(GetCurrentProcess()); +} + +class TestDirectory { +public: + TestDirectory() { + GUID identifier{}; + require(SUCCEEDED(CoCreateGuid(&identifier)), "create repair fixture identifier"); + std::array text{}; + require(StringFromGUID2(identifier, text.data(), static_cast(text.size())) != 0, + "format repair fixture identifier"); + path_ = fs::temp_directory_path() / + (L"D2SteamFix repair tests " + std::wstring(text.data())); + require(fs::create_directory(path_), "create repair fixture directory"); + } + ~TestDirectory() noexcept { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + TestDirectory(const TestDirectory&) = delete; + TestDirectory& operator=(const TestDirectory&) = delete; + TestDirectory(TestDirectory&&) = delete; + TestDirectory& operator=(TestDirectory&&) = delete; + [[nodiscard]] const fs::path& path() const noexcept { return path_; } +private: + fs::path path_; +}; + +enum class PickerKind { selection, cancel, failure }; + +struct PickerAction { + PickerKind kind; + fs::path path; +}; + +fs::path make_game_directory(const fs::path& root) { + const fs::path game = root / L"game"; + require(fs::create_directory(game), "create repair fixture game directory"); + return game; +} + +struct Plan { + TestDirectory temporary; + fs::path game = make_game_directory(temporary.path()); + fs::path steam = temporary.path() / L"Steam"; + fs::path recovery = game / L"steamfix.dat"; + const steamfix::Logger logger{game / L"steamfix.log"}; + std::map owned_processes; + std::vector process_lifetimes; + std::vector picker_actions; + std::size_t picker_index = 0; + DWORD picker_error = 0; + int confirmation = IDOK; + HRESULT confirmation_status = S_OK; + bool discovery_failure = false; + bool broker_cancelled = false; + DWORD broker_error = ERROR_SUCCESS; + bool broker_app_error = false; + bool snapshot_failure = false; + DWORD launch_error = ERROR_SUCCESS; + bool desktop_launch = false; + int confirmations = 0; + int broker_calls = 0; + int launches = 0; + std::wstring launched_command; + std::vector information; + std::vector errors; + bool message_recording_failed = false; + // The watchdog joins before these fields or the temporary tree are destroyed. + std::atomic watchdog_fired{false}; + std::jthread watchdog; + + explicit Plan(const bool renderer32 = true, const std::string_view recreation = "both") { + fs::create_directory(steam); + fs::copy_file(executable_path(), steam / L"steam.exe"); + write_file(steam / L".d2steamfix-test-owner", + "D2SteamFix repair fixture\n" + steamfix::narrow(fs::canonical(steam).native()) + "\n"); + write_file(steam / L".d2steamfix-test-recreation", recreation); + write_file(steam / L"GameOverlayRenderer64.dll", "original64"); + if (renderer32) write_file(steam / L"GameOverlayRenderer.dll", "original32"); + for (const auto* name : {L"steamfix.dat", L"steamfix.dat.tmp", L"steamfix.dat.old"}) { + write_file(game / name, "preserved recovery state"); + } + logger.info("repair workflow fixture log must survive repair"); + } + + [[nodiscard]] const steamfix::Logger& log() const noexcept { return logger; } + [[nodiscard]] bool owns(const fs::path& path) const { + const fs::path relative = fs::weakly_canonical(path).lexically_relative(fs::canonical(temporary.path())); + return !relative.empty() && !relative.is_absolute() && *relative.begin() != L".."; + } + [[nodiscard]] bool owns_pid(const DWORD pid) const noexcept { + return pid == GetCurrentProcessId() || owned_processes.contains(pid); + } +}; + +// Scope is confined to the calling test thread. Separately compiled production +// dependencies and the child executable retain their original native bindings. +thread_local Plan* active = nullptr; + +class Scope { +public: + explicit Scope(Plan& plan) { + require(active == nullptr, "nested repair fixture scope"); + active = &plan; + } + ~Scope() noexcept { active = nullptr; } + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + Scope(Scope&&) = delete; + Scope& operator=(Scope&&) = delete; +}; + +UniqueHandle duplicate_process(const HANDLE process) { + HANDLE duplicate = nullptr; + if (!DuplicateHandle(GetCurrentProcess(), process, GetCurrentProcess(), &duplicate, + 0, FALSE, DUPLICATE_SAME_ACCESS)) fail(GetLastError()); + return UniqueHandle(duplicate); +} + +class OwnedChild { +public: + OwnedChild(Plan& plan, const fs::path& executable) { + require(plan.owns(executable), "child executable escaped repair fixture"); + fs::create_directories(executable.parent_path()); + if (!fs::exists(executable)) fs::copy_file(executable_path(), executable); + std::wstring command; + steamfix::command_line::append_quoted(command, executable.native()); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + if (!::CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, + CREATE_SUSPENDED | CREATE_NO_WINDOW, nullptr, + executable.parent_path().c_str(), &startup, &information)) fail(GetLastError()); + process_ = UniqueHandle(information.hProcess); + thread_ = UniqueHandle(information.hThread); + try { + plan.owned_processes.emplace(information.dwProcessId, fs::canonical(executable)); + plan.process_lifetimes.push_back(duplicate_process(process_.get())); + } catch (...) { + ::TerminateProcess(process_.get(), 1); + ::WaitForSingleObject(process_.get(), 5000); + throw; + } + } + ~OwnedChild() noexcept { + if (process_.get() != nullptr && ::WaitForSingleObject(process_.get(), 0) == WAIT_TIMEOUT) { + ::TerminateProcess(process_.get(), 1); + ::WaitForSingleObject(process_.get(), 5000); + } + } + OwnedChild(const OwnedChild&) = delete; + OwnedChild& operator=(const OwnedChild&) = delete; + OwnedChild(OwnedChild&&) = delete; + OwnedChild& operator=(OwnedChild&&) = delete; + [[nodiscard]] bool alive() const noexcept { return ::WaitForSingleObject(process_.get(), 0) == WAIT_TIMEOUT; } + void require_repair_shutdown() const { + require(::WaitForSingleObject(process_.get(), 0) == WAIT_OBJECT_0, "repair left its owned target alive"); + DWORD code = 0; + if (!GetExitCodeProcess(process_.get(), &code)) fail(GetLastError()); + require(code == ERROR_CANCELLED, "repair did not use its documented shutdown status"); + } +private: + UniqueHandle process_; + UniqueHandle thread_; +}; + +HANDLE WINAPI snapshot(const DWORD flags, const DWORD pid) { + if (active != nullptr) { + require(flags == TH32CS_SNAPPROCESS && pid == 0, "unexpected repair snapshot scope"); + if (active->snapshot_failure) { + SetLastError(ERROR_NOT_ENOUGH_MEMORY); + return INVALID_HANDLE_VALUE; + } + } + return ::CreateToolhelp32Snapshot(flags, pid); +} + +BOOL WINAPI first_process(const HANDLE snapshot_handle, const LPPROCESSENTRY32W entry) { + BOOL found = ::Process32FirstW(snapshot_handle, entry); + while (found && active != nullptr && !active->owns_pid(entry->th32ProcessID)) { + found = ::Process32NextW(snapshot_handle, entry); + } + return found; +} + +BOOL WINAPI next_process(const HANDLE snapshot_handle, const LPPROCESSENTRY32W entry) { + BOOL found = ::Process32NextW(snapshot_handle, entry); + while (found && active != nullptr && !active->owns_pid(entry->th32ProcessID)) { + found = ::Process32NextW(snapshot_handle, entry); + } + return found; +} + +HANDLE WINAPI create_mutex(LPSECURITY_ATTRIBUTES security, const BOOL owner, const LPCWSTR name) { + if (active == nullptr) return ::CreateMutexW(security, owner, name); + require(name != nullptr, "unnamed production repair mutex"); + const std::wstring_view requested(name); + require(requested == L"Global\\D2SteamFix-Repair-1085660" || requested == L"Global\\D2SteamFix-1085660", + "unexpected production repair mutex name"); + const std::wstring isolated = L"Local\\D2SteamFix-RepairTest-" + + active->temporary.path().filename().native() + L"-" + + (requested == L"Global\\D2SteamFix-Repair-1085660" ? L"repair" : L"session"); + return ::CreateMutexW(security, owner, isolated.c_str()); +} + +HRESULT WINAPI task_dialog(const TASKDIALOGCONFIG* dialog, int* selected, int* radio, BOOL* checked) { + if (active == nullptr) return ::TaskDialogIndirect(dialog, selected, radio, checked); + if (dialog == nullptr || selected == nullptr) throw std::runtime_error("repair confirmation omitted its configuration"); + require(dialog->nDefaultButton == IDCANCEL && dialog->cButtons == 2 && + (dialog->dwFlags & TDF_ALLOW_DIALOG_CANCELLATION) != 0, "repair confirmation lost safe cancellation defaults"); + require(std::wstring_view(dialog->pszMainInstruction).find(L"Steam and Destiny 2 will close") != std::wstring_view::npos, + "repair confirmation did not disclose application shutdown"); + ++active->confirmations; + *selected = active->confirmation; + return active->confirmation_status; +} + +BOOL WINAPI pick_file(LPOPENFILENAMEW picker) { + if (active == nullptr) return ::GetOpenFileNameW(picker); + if (picker == nullptr || picker->lpstrFile == nullptr) throw std::runtime_error("repair picker omitted its output buffer"); + require((picker->Flags & (OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR)) == + (OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR), "repair picker lost safe path-selection flags"); + require(active->picker_index < active->picker_actions.size(), "unexpected extra repair file-picker request"); + const PickerAction& action = active->picker_actions.at(active->picker_index++); + active->picker_error = 0; + if (action.kind == PickerKind::cancel) return FALSE; + if (action.kind == PickerKind::failure) { + active->picker_error = CDERR_INITIALIZATION; + return FALSE; + } + require(active->owns(action.path), "picker selection escaped repair fixture"); + const std::wstring selected = action.path.native(); + require(selected.size() < picker->nMaxFile, "repair picker output buffer is too small"); + std::copy(selected.begin(), selected.end(), picker->lpstrFile); + picker->lpstrFile[selected.size()] = L'\0'; + return TRUE; +} + +DWORD WINAPI picker_error() { + return active != nullptr ? active->picker_error : ::CommDlgExtendedError(); +} + +BOOL WINAPI create_process(LPCWSTR executable, LPWSTR command, LPSECURITY_ATTRIBUTES process_security, + LPSECURITY_ATTRIBUTES thread_security, const BOOL inherit, const DWORD flags, + LPVOID environment, LPCWSTR directory, LPSTARTUPINFOW startup, + LPPROCESS_INFORMATION information) { + if (active == nullptr) { + return ::CreateProcessW(executable, command, process_security, thread_security, inherit, + flags, environment, directory, startup, information); + } + if (executable == nullptr || directory == nullptr || command == nullptr || information == nullptr) { + throw std::runtime_error("repair launch omitted its explicit executable, working directory or process output"); + } + require(active->owns(fs::path(executable)) && + steamfix::paths::equal_windows(fs::path(executable), active->steam / L"steam.exe") && + steamfix::paths::equal_windows(fs::path(directory), active->steam), "repair child launch escaped fixture"); + require(std::wstring_view(command).find(L" -forcesteamupdate -forcepackagedownload -exitsteam") != std::wstring_view::npos, + "repair launch omitted its actual Steam repair flags"); + ++active->launches; + active->launched_command = command; + if (active->launch_error != ERROR_SUCCESS) { + SetLastError(active->launch_error); + return FALSE; + } + if (!::CreateProcessW(executable, command, process_security, thread_security, inherit, + flags | CREATE_NO_WINDOW, environment, directory, startup, information)) return FALSE; + UniqueHandle process(information->hProcess); + UniqueHandle thread(information->hThread); + try { + active->owned_processes.emplace(information->dwProcessId, fs::canonical(fs::path(executable))); + active->process_lifetimes.push_back(duplicate_process(process.get())); + UniqueHandle watched = duplicate_process(process.get()); + Plan* const plan = active; + active->watchdog = std::jthread([plan, watched = std::move(watched)] { + if (::WaitForSingleObject(watched.get(), 8000) == WAIT_TIMEOUT) { + plan->watchdog_fired.store(true); + ::TerminateProcess(watched.get(), 98); + ::WaitForSingleObject(watched.get(), 5000); + } + }); + } catch (...) { + ::TerminateProcess(process.get(), 1); + ::WaitForSingleObject(process.get(), 5000); + throw; + } + information->hProcess = process.release(); + information->hThread = thread.release(); + return TRUE; +} + +} // namespace repair_workflow + +namespace steamfix::repair_fixture_steam { +std::filesystem::path discover() { + if (repair_workflow::active == nullptr) return steamfix::steam::discover(); + if (repair_workflow::active->discovery_failure) throw std::runtime_error("controlled fixture discovery failure"); + return repair_workflow::active->steam; +} +} // namespace steamfix::repair_fixture_steam + +namespace steamfix::repair_fixture_elevation { + +UniqueHandle open_file(const std::filesystem::path& path, const DWORD access, const DWORD share, + const DWORD creation, const DWORD flags, SECURITY_ATTRIBUTES* security = nullptr) { + if (repair_workflow::active == nullptr) return steamfix::elevation::open_file(path, access, share, creation, flags, security); + repair_workflow::require(repair_workflow::active->owns(path), "repair file access escaped fixture"); + // Use the same native first attempt, without any possible broker/UAC fallback. + return UniqueHandle(::CreateFileW(path.c_str(), access, share, security, creation, flags, nullptr)); +} + +UniqueHandle open_process_for_termination(const DWORD pid) { + if (repair_workflow::active == nullptr) return steamfix::elevation::open_process_for_termination(pid); + const auto found = repair_workflow::active->owned_processes.find(pid); + if (found == repair_workflow::active->owned_processes.end()) throw std::runtime_error("shutdown attempted an unowned process ID"); + UniqueHandle handle(::OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, pid)); + const auto actual = steamfix::process::image_path(handle.get()); + repair_workflow::require(repair_workflow::active->owns(actual) && steamfix::paths::equal_windows(actual, found->second), + "shutdown process identity no longer matches its owned executable"); + return handle; +} + +void ensure_broker() { + if (repair_workflow::active == nullptr) { + steamfix::elevation::ensure_broker(); + return; + } + ++repair_workflow::active->broker_calls; + if (repair_workflow::active->broker_cancelled) throw steamfix::elevation::Cancelled(); + if (repair_workflow::active->broker_error != ERROR_SUCCESS) repair_workflow::fail(repair_workflow::active->broker_error); + if (repair_workflow::active->broker_app_error) throw AppError(ExitCode::validation, "fixture broker", "controlled application error"); +} + +bool is_elevated() { + return repair_workflow::active != nullptr ? false : steamfix::elevation::is_elevated(); +} + +bool launch_as_desktop_user(const std::filesystem::path& executable, const std::wstring& command, + const std::filesystem::path& directory, PROCESS_INFORMATION& information) { + if (repair_workflow::active == nullptr) { + return steamfix::elevation::launch_as_desktop_user(executable, command, directory, information); + } + if (!repair_workflow::active->desktop_launch) return false; + std::wstring mutable_command(command); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + if (!repair_workflow::create_process(executable.c_str(), mutable_command.data(), nullptr, nullptr, + FALSE, 0, nullptr, directory.c_str(), &startup, &information)) { + repair_workflow::fail(GetLastError()); + } + return true; +} + +} // namespace steamfix::repair_fixture_elevation + +// Headless UI endpoints record only messages produced during an active fixture. +namespace steamfix::ui { +void info(const std::wstring_view message) noexcept { + if (repair_workflow::active == nullptr) return; + try { repair_workflow::active->information.emplace_back(message); } + catch (...) { repair_workflow::active->message_recording_failed = true; } +} +void error(const std::wstring_view message) noexcept { + if (repair_workflow::active == nullptr) return; + try { repair_workflow::active->errors.emplace_back(message); } + catch (...) { repair_workflow::active->message_recording_failed = true; } +} +} // namespace steamfix::ui + +#define TaskDialogIndirect repair_workflow::task_dialog +#define GetOpenFileNameW repair_workflow::pick_file +#define CommDlgExtendedError repair_workflow::picker_error +#define CreateToolhelp32Snapshot repair_workflow::snapshot +#define Process32FirstW repair_workflow::first_process +#define Process32NextW repair_workflow::next_process +#define CreateMutexW repair_workflow::create_mutex +#define CreateProcessW repair_workflow::create_process +#define steam repair_fixture_steam +#define elevation repair_fixture_elevation +#include "../src/repair.cpp" +#undef elevation +#undef steam +#undef CreateProcessW +#undef CreateMutexW +#undef Process32NextW +#undef Process32FirstW +#undef CreateToolhelp32Snapshot +#undef CommDlgExtendedError +#undef GetOpenFileNameW +#undef TaskDialogIndirect + +namespace repair_workflow { + +void require_recovery(const Plan& plan, const bool present) { + for (const auto* name : {L"steamfix.dat", L"steamfix.dat.tmp", L"steamfix.dat.old"}) { + require(fs::exists(plan.game / name) == present, "repair recovery artifact preservation/cleanup was incorrect"); + } + require(fs::is_regular_file(plan.game / L"steamfix.log") && fs::file_size(plan.game / L"steamfix.log") != 0, + "repair removed or truncated its log"); + require(!plan.watchdog_fired.load(), "repair child exceeded its eight-second fixture watchdog"); + require(!plan.message_recording_failed, "headless repair message recording failed"); +} + +template +void expect_repair_failure(Operation operation, const steamfix::ExitCode expected = steamfix::ExitCode::overlay_protection) { + try { + operation(); + } catch (const steamfix::AppError& error) { + require(error.code() == expected, "repair failure returned an unexpected exit category"); + return; + } + throw std::runtime_error("expected repair failure was accepted"); +} + +void check_cancellation() { + { + Plan plan; + const OwnedChild game(plan, plan.game / L"destiny2.exe"); + const OwnedChild steam(plan, plan.steam / L"steam.exe"); + plan.confirmation = IDCANCEL; + const Scope scope(plan); + require(!steamfix::repair::run(plan.game, plan.recovery, plan.log()), "declined repair consent was ignored"); + require(game.alive() && steam.alive(), "consent cancellation closed a fixture application"); + require(read_file(plan.steam / L"GameOverlayRenderer64.dll") == "original64", "consent cancellation modified renderer"); + require(plan.confirmations == 1 && plan.broker_calls == 0 && plan.launches == 0, "cancelled repair continued into broker/launch"); + require_recovery(plan, true); + } + { + Plan plan; + const OwnedChild game(plan, plan.game / L"destiny2.exe"); + const OwnedChild steam(plan, plan.steam / L"steam.exe"); + plan.broker_cancelled = true; + const Scope scope(plan); + require(!steamfix::repair::run(plan.game, plan.recovery, plan.log()), "broker cancellation was ignored"); + require(game.alive() && steam.alive(), "broker cancellation closed a fixture application"); + require(plan.broker_calls == 1 && plan.launches == 0, "cancelled broker continued into repair launch"); + require(read_file(plan.steam / L"GameOverlayRenderer64.dll") == "original64", "broker cancellation modified renderer"); + require_recovery(plan, true); + } +} + +void check_success(const bool renderer32, const bool desktop_launch, const bool use_picker) { + Plan plan(renderer32, renderer32 ? "both" : "64"); + const OwnedChild game(plan, plan.game / L"destiny2.exe"); + const OwnedChild launcher(plan, plan.game / L"destiny2launcher.exe"); + const OwnedChild steam(plan, plan.steam / L"steam.exe"); + const OwnedChild unrelated_service(plan, plan.temporary.path() / L"unrelated" / L"steamservice.exe"); + plan.desktop_launch = desktop_launch; + plan.discovery_failure = use_picker; + if (use_picker) { + plan.picker_actions = {{PickerKind::selection, plan.steam / L"not-steam.exe"}, + {PickerKind::selection, plan.steam / L"steam.exe"}}; + } + const Scope scope(plan); + require(steamfix::repair::run(plan.game, plan.recovery, plan.log()), "owned fixture repair did not succeed"); + game.require_repair_shutdown(); + launcher.require_repair_shutdown(); + steam.require_repair_shutdown(); + require(unrelated_service.alive(), "repair closed an unrelated fixture service outside Steam installation"); + require(read_file(plan.steam / L"GameOverlayRenderer64.dll") == "repaired64", "repair did not recreate 64-bit renderer"); + if (renderer32) require(read_file(plan.steam / L"GameOverlayRenderer.dll") == "repaired32", "repair did not recreate 32-bit renderer"); + else require(!fs::exists(plan.steam / L"GameOverlayRenderer.dll"), "repair created an unrequested optional renderer"); + require(plan.confirmations == 1 && plan.broker_calls == 1 && plan.launches == 1, "successful repair repeated or skipped a workflow stage"); + require(plan.information.size() == 1 && plan.information.front().find(L"Steam repaired its files") != std::wstring::npos, + "successful repair did not report completion"); + require(plan.picker_index == (use_picker ? 2U : 0U) && plan.errors.size() == (use_picker ? 1U : 0U), + "repair picker did not reject invalid selection and accept valid Steam executable"); + require_recovery(plan, false); +} + +void check_failures() { + { + Plan plan; + const Scope scope(plan); + expect_repair_failure([&] { static_cast(steamfix::repair::run(plan.game, plan.game / L"wrong.dat", plan.log())); }); + require(plan.confirmations == 0 && plan.launches == 0, "invalid recovery path reached consent or launch"); + require_recovery(plan, true); + } + { + Plan plan; + plan.confirmation_status = E_FAIL; + const Scope scope(plan); + expect_repair_failure([&] { static_cast(steamfix::repair::run(plan.game, plan.recovery, plan.log())); }); + require(plan.broker_calls == 0, "failed consent display reached broker"); + require_recovery(plan, true); + } + { + Plan plan; + plan.broker_error = ERROR_ACCESS_DENIED; + const Scope scope(plan); + expect_repair_failure([&] { static_cast(steamfix::repair::run(plan.game, plan.recovery, plan.log())); }); + require(plan.launches == 0, "failed broker reached child launch"); + require_recovery(plan, true); + } + { + Plan plan; + plan.broker_app_error = true; + const Scope scope(plan); + expect_repair_failure([&] { static_cast(steamfix::repair::run(plan.game, plan.recovery, plan.log())); }, + steamfix::ExitCode::validation); + require_recovery(plan, true); + } + { + Plan plan; + const OwnedChild game(plan, plan.game / L"destiny2.exe"); + plan.snapshot_failure = true; + const Scope scope(plan); + expect_repair_failure([&] { static_cast(steamfix::repair::run(plan.game, plan.recovery, plan.log())); }); + require(game.alive() && plan.launches == 0, "snapshot failure closed a process or launched repair"); + require(read_file(plan.steam / L"GameOverlayRenderer64.dll") == "original64", "snapshot failure modified renderer"); + require_recovery(plan, true); + } + { + Plan plan; + plan.launch_error = ERROR_ACCESS_DENIED; + const Scope scope(plan); + expect_repair_failure([&] { static_cast(steamfix::repair::run(plan.game, plan.recovery, plan.log())); }); + require(plan.launches == 1 && plan.information.empty(), "failed repair launch reported completion"); + require_recovery(plan, true); + } + for (const auto mode : {"none", "64"}) { + Plan plan(true, mode); + const Scope scope(plan); + expect_repair_failure([&] { static_cast(steamfix::repair::run(plan.game, plan.recovery, plan.log())); }); + require(plan.launches == 1 && plan.information.empty(), "failed renderer recreation reported completion"); + require_recovery(plan, true); + } +} + +void check_picker_cancellation_and_failure() { + { + Plan plan; + plan.discovery_failure = true; + plan.picker_actions = {{PickerKind::cancel, {}}}; + const Scope scope(plan); + require(!steamfix::repair::run(plan.game, plan.recovery, plan.log()), "cancelled Steam picker continued repair"); + require(plan.confirmations == 0 && plan.broker_calls == 0, "cancelled Steam picker reached consent or broker"); + require_recovery(plan, true); + } + { + Plan plan; + plan.discovery_failure = true; + plan.picker_actions = {{PickerKind::failure, {}}}; + const Scope scope(plan); + expect_repair_failure([&] { static_cast(steamfix::repair::run(plan.game, plan.recovery, plan.log())); }); + require(plan.confirmations == 0 && plan.broker_calls == 0, "failed Steam picker reached consent or broker"); + require_recovery(plan, true); + } +} + +int recreate_owned_renderers(const std::vector& arguments) { + if (arguments.size() != 4 || arguments[1] != L"-forcesteamupdate" || + arguments[2] != L"-forcepackagedownload" || arguments[3] != L"-exitsteam") return 90; + const fs::path directory = fs::canonical(fs::current_path()); + if (!directory.parent_path().filename().native().starts_with(L"D2SteamFix repair tests ") || + !steamfix::paths::equal_windows(executable_path(), directory / L"steam.exe")) return 91; + const std::string marker = "D2SteamFix repair fixture\n" + steamfix::narrow(directory.native()) + "\n"; + if (read_file(directory / L".d2steamfix-test-owner") != marker) return 92; + const std::string mode = read_file(directory / L".d2steamfix-test-recreation"); + if (mode == "none") return 42; + if (mode != "both" && mode != "64") return 93; + write_file(directory / L"GameOverlayRenderer64.dll", "repaired64"); + if (mode == "both") write_file(directory / L"GameOverlayRenderer.dll", "repaired32"); + return 0; +} + +struct CommandLineDeleter { + void operator()(wchar_t** arguments) const noexcept { LocalFree(arguments); } +}; + +} // namespace repair_workflow + +int main() { + try { + int count = 0; + const std::unique_ptr arguments(CommandLineToArgvW(GetCommandLineW(), &count)); + if (!arguments || count <= 0) throw std::runtime_error("could not read repair workflow arguments"); + const std::span parsed{arguments.get(), static_cast(count)}; + std::vector owned_arguments; + owned_arguments.reserve(parsed.size()); + for (const auto argument : parsed) { + if (argument == nullptr) throw std::runtime_error("repair workflow argument was null"); + owned_arguments.emplace_back(argument); + } + if (steamfix::paths::name_is(repair_workflow::executable_path(), L"steam.exe")) { + return repair_workflow::recreate_owned_renderers(owned_arguments); + } + repair_workflow::require(count == 1, "unexpected repair workflow test arguments"); + repair_workflow::check_cancellation(); + repair_workflow::check_picker_cancellation_and_failure(); + repair_workflow::check_success(true, false, false); + repair_workflow::check_success(false, true, true); + repair_workflow::check_failures(); + std::cout << "Repair workflow cancellation, owned process shutdown, launch, verification and cleanup passed.\n"; + } catch (const std::exception& error) { + std::cerr << "Repair workflow test failed: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/tests/runtime_tests.cpp b/tests/runtime_tests.cpp new file mode 100644 index 0000000..75dabaf --- /dev/null +++ b/tests/runtime_tests.cpp @@ -0,0 +1,612 @@ +// Compile the production implementations into this test executable so their +// internal safety decisions are exercised without adding public test hooks. +#include "../src/paths.cpp" +#include "../src/process.cpp" +#include "../src/repair.cpp" +#include "../src/steam.cpp" +#include "../src/overlay.cpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using steamfix::UniqueHandle; + +void require(const bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +template + requires std::derived_from +void expect_failure(Operation operation, const char* message) { + try { + operation(); + } catch (const Exception&) { + return; + } + throw std::runtime_error(message); +} + +void check_status(const LSTATUS status) { + if (status != ERROR_SUCCESS) { + throw std::system_error(steamfix::windows_exit_status(static_cast(status)), + std::system_category()); + } +} + +template +void expect_windows_failure(Operation operation, const DWORD expected, const char* message) { + try { + operation(); + } catch (const std::system_error& error) { + require(static_cast(error.code().value()) == expected, message); + return; + } + throw std::runtime_error(message); +} + +class TemporaryDirectory { +public: + TemporaryDirectory() { + for (int attempt = 0; attempt < 10; ++attempt) { + path_ = fs::temp_directory_path() / + (L"D2SteamFix-runtime-" + std::to_wstring(GetCurrentProcessId()) + L"-" + + std::to_wstring(GetTickCount64()) + L"-" + std::to_wstring(attempt)); + if (fs::create_directory(path_)) return; + } + throw std::runtime_error("could not create an isolated runtime test directory"); + } + ~TemporaryDirectory() noexcept { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + TemporaryDirectory(const TemporaryDirectory&) = delete; + TemporaryDirectory& operator=(const TemporaryDirectory&) = delete; + TemporaryDirectory(TemporaryDirectory&&) = delete; + TemporaryDirectory& operator=(TemporaryDirectory&&) = delete; + [[nodiscard]] const fs::path& path() const noexcept { return path_; } +private: + fs::path path_; +}; + +void write_file(const fs::path& path) { + std::ofstream file(path, std::ios::binary); + file << "test-owned file\n"; + file.close(); + require(!file.fail(), "could not write a runtime test fixture"); +} + +class OwnedChild { +public: + explicit OwnedChild(const fs::path& executable) { + std::wstring line; + steamfix::command_line::append_quoted(line, executable.native()); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + if (!CreateProcessW(executable.c_str(), line.data(), nullptr, nullptr, FALSE, + CREATE_SUSPENDED | CREATE_NO_WINDOW, nullptr, + executable.parent_path().c_str(), &startup, &information)) { + throw std::system_error(steamfix::windows_exit_status(GetLastError()), + std::system_category()); + } + process_ = UniqueHandle(information.hProcess); + thread_ = UniqueHandle(information.hThread); + pid_ = information.dwProcessId; + } + ~OwnedChild() noexcept { + if (process_.get() != nullptr) { + // This handle comes only from this fixture's CreateProcess call. + TerminateProcess(process_.get(), 0); + WaitForSingleObject(process_.get(), 5000); + } + } + OwnedChild(const OwnedChild&) = delete; + OwnedChild& operator=(const OwnedChild&) = delete; + OwnedChild(OwnedChild&&) = delete; + OwnedChild& operator=(OwnedChild&&) = delete; + [[nodiscard]] HANDLE get() const noexcept { return process_.get(); } + [[nodiscard]] DWORD pid() const noexcept { return pid_; } + void finish(const DWORD status) { + require(TerminateProcess(process_.get(), status) != FALSE, + "could not finish a test-owned child"); + require(WaitForSingleObject(process_.get(), 5000) == WAIT_OBJECT_0, + "test-owned child did not exit"); + } +private: + UniqueHandle process_; + UniqueHandle thread_; + DWORD pid_ = 0; +}; + +void check_release_state() { + using steamfix::process::ReleaseState; + const auto start = ReleaseState::Clock::time_point{}; + const auto at = [start](const int seconds) { + return start + std::chrono::seconds(seconds); + }; + + ReleaseState unseen; + require(!unseen.observe(false, false, true, at(0)), "initial absence must start grace"); + require(!unseen.observe(false, false, true, at(29)), "unseen game must retain 30-second grace"); + require(!unseen.observe(false, false, true, at(30)), "grace expiry needs a debounce poll"); + require(unseen.observe(false, false, true, at(31)), "two absences after grace should release"); + + ReleaseState played; + require(!played.observe(true, false, true, at(0)), "live launcher must retain protection"); + require(!played.observe(false, true, true, at(1)), "live game must retain protection"); + require(!played.observe(false, false, true, at(2)), "first absence must retain protection"); + require(!played.observe(false, false, false, at(3)), "unreliable snapshot must retain protection"); + require(!played.observe(false, false, true, at(4)), "unreliable snapshot must reset debounce"); + require(played.observe(false, false, true, at(5)), "observed game needs two reliable absences"); + + ReleaseState restarted; + require(!restarted.observe(false, false, true, at(0)), "absence should start grace"); + require(!restarted.observe(true, false, true, at(30)), "restarted launcher should reset grace"); + require(!restarted.observe(false, false, true, at(31)), "new absence should start fresh grace"); + require(!restarted.observe(false, false, true, at(60)), "fresh grace must last 30 seconds"); + require(!restarted.observe(false, false, true, at(61)), "fresh grace needs first negative poll"); + require(restarted.observe(false, false, true, at(62)), "fresh grace should release after debounce"); + + ReleaseState uncertain_game; + require(!uncertain_game.observe(false, true, false, at(0)), "uncertain live game retains protection"); + require(!uncertain_game.observe(false, false, true, at(1)), "observed game remains remembered"); + require(uncertain_game.observe(false, false, true, at(2)), "observed game skips launcher-only grace"); + + ReleaseState interrupted_grace; + require(!interrupted_grace.observe(false, false, true, at(0)), "grace should begin"); + require(!interrupted_grace.observe(false, false, false, at(29)), "uncertainty must reset grace"); + require(!interrupted_grace.observe(false, false, true, at(30)), "reliable observation restarts grace"); + require(!interrupted_grace.observe(false, false, true, at(59)), "reset grace must remain active"); + require(!interrupted_grace.observe(false, false, true, at(60)), "reset grace first negative poll"); + require(interrupted_grace.observe(false, false, true, at(61)), "reset grace second negative poll"); + + int failures = 0; + steamfix::process::increment_saturated(failures); + require(failures == 1, "poll counter must increment"); + failures = std::numeric_limits::max() - 1; + steamfix::process::increment_saturated(failures); + steamfix::process::increment_saturated(failures); + require(failures == std::numeric_limits::max(), "poll counter must saturate without overflow"); +} + +void check_paths_and_installations(const fs::path& root) { + const fs::path steam = root / L"Steam"; + fs::create_directories(steam / L"bin" / L"helpers"); + write_file(steam / L"steam.exe"); + require(steamfix::paths::name_is(steam / L"STEAM.EXE", L"steam.exe"), "names must ignore case"); + require(!steamfix::paths::name_is(steam / L"steam.exe.old", L"steam.exe"), "names must match whole basename"); + require(!steamfix::paths::name_is(steam / L"steam.exe", L""), "different name lengths must differ"); + require(steamfix::paths::equal_windows(steam, steam / L"."), "equivalent directories must compare equal"); + require(steamfix::paths::equal_windows(root / L"missing", root / L"MISSING"), "missing paths compare without case"); + require(!steamfix::paths::equal_windows(root / L"first", root / L"second"), "different paths must differ"); + require(steamfix::steam::verify(steam) == fs::canonical(steam), "Steam directory must canonicalize"); + require(steamfix::steam::verify(steam / L"steam.exe") == fs::canonical(steam), "Steam executable must resolve directory"); + expect_failure([&] { static_cast(steamfix::steam::verify(root / L"absent")); }, + "missing Steam installation was accepted"); + require(steamfix::repair::validate_steam(steam / L"steam.exe") == fs::canonical(steam), + "repair must accept a regular Steam executable"); + expect_failure([&] { static_cast(steamfix::repair::validate_steam(steam / L"other.exe")); }, + "repair accepted a different executable name"); + expect_failure([&] { static_cast(steamfix::repair::validate_steam(root / L"steam.exe")); }, + "repair accepted a missing Steam executable"); + + require(steamfix::repair::destiny_name(L"DESTINY2.EXE"), "game name comparison must ignore case"); + require(steamfix::repair::destiny_name(L"destiny2launcher.exe"), "launcher name must be recognized"); + require(!steamfix::repair::destiny_name(L"other.exe"), "unrelated game name accepted"); + for (const auto* name : {L"steam.exe", L"steamwebhelper.exe", L"steamservice.exe", L"GameOverlayUI.exe", + L"steamerrorreporter.exe", L"steamerrorreporter64.exe", L"steam_monitor.exe"}) { + require(steamfix::repair::steam_name(name), "documented Steam process name was rejected"); + } + require(!steamfix::repair::steam_name(L"steam.exe.old"), "unrelated Steam process name accepted"); + require(steamfix::repair::inside_steam(steam / L"steam.exe", steam), "Steam root executable must be inside installation"); + require(steamfix::repair::inside_steam(steam / L"bin" / L"helpers" / L"helper.exe", steam), "Steam helper must be inside installation"); + require(!steamfix::repair::inside_steam(steam / L"steamapps" / L"game.exe", steam), "steamapps executable must be excluded"); + require(!steamfix::repair::inside_steam(root / L"other.exe", steam), "outside executable must be excluded"); + fs::path deep = steam; + for (int depth = 0; depth < 9; ++depth) deep /= L"nested"; + require(!steamfix::repair::inside_steam(deep / L"helper.exe", steam), "containment must respect ancestor limit"); + require(!steamfix::repair::shared_steam_service(steam / L"other.exe"), "unrelated executable cannot be shared service"); + require(!steamfix::repair::shared_steam_service(steam / L"steamservice.exe"), "nonshared service location accepted"); + for (const int folder : {CSIDL_PROGRAM_FILES_COMMONX86, CSIDL_PROGRAM_FILES_COMMON}) { + std::array common{}; + if (SUCCEEDED(SHGetFolderPathW(nullptr, folder, nullptr, SHGFP_TYPE_CURRENT, common.data()))) { + const fs::path service = fs::path(common.data()) / L"Steam" / L"SteamService.exe"; + require(steamfix::repair::shared_steam_service(service), "shared Steam service location was rejected"); + require(steamfix::repair::steam_process(service, steam), "shared Steam service process was rejected"); + } + } + require(steamfix::repair::steam_process(steam / L"steam.exe", steam), "selected Steam installation was rejected"); + require(steamfix::repair::steam_process(steam / L"bin" / L"helper.exe", root / L"different"), + "another validated Steam installation must be recognized"); + require(!steamfix::repair::steam_process(steam / L"steamapps" / L"game.exe", root / L"different"), + "game install cannot count as Steam installation"); + require(!steamfix::repair::steam_process(root / L"elsewhere" / L"helper.exe", steam), + "unvalidated executable location accepted"); + require(!steamfix::repair::steam_process(deep / L"helper.exe", root / L"different"), + "Steam installation search exceeded its ancestor limit"); +} + +struct CloseRegistryKey { + void operator()(HKEY key) const noexcept { RegCloseKey(key); } +}; +using TestRegistryKey = std::unique_ptr, CloseRegistryKey>; + +TestRegistryKey create_key(const HKEY parent, const wchar_t* name) { + HKEY raw = nullptr; + check_status(RegCreateKeyExW(parent, name, 0, nullptr, REG_OPTION_VOLATILE, + KEY_ALL_ACCESS, nullptr, &raw, nullptr)); + return TestRegistryKey(raw); +} + +class RegistryTree { +public: + RegistryTree() + : path_(L"Software\\D2SteamFixTests\\" + std::to_wstring(GetCurrentProcessId()) + + L"-" + std::to_wstring(GetTickCount64())), + key_(create_key(HKEY_CURRENT_USER, path_.c_str())) {} + ~RegistryTree() noexcept { + key_.reset(); + RegDeleteTreeW(HKEY_CURRENT_USER, path_.c_str()); + } + RegistryTree(const RegistryTree&) = delete; + RegistryTree& operator=(const RegistryTree&) = delete; + RegistryTree(RegistryTree&&) = delete; + RegistryTree& operator=(RegistryTree&&) = delete; + [[nodiscard]] HKEY get() const noexcept { return key_.get(); } +private: + std::wstring path_; + TestRegistryKey key_; +}; + +class RegistryOverride { +public: + explicit RegistryOverride(const HKEY isolated_root) { + check_status(RegOverridePredefKey(HKEY_CURRENT_USER, isolated_root)); + } + ~RegistryOverride() noexcept { RegOverridePredefKey(HKEY_CURRENT_USER, nullptr); } + RegistryOverride(const RegistryOverride&) = delete; + RegistryOverride& operator=(const RegistryOverride&) = delete; + RegistryOverride(RegistryOverride&&) = delete; + RegistryOverride& operator=(RegistryOverride&&) = delete; +}; + +class EnvironmentVariable { +public: + EnvironmentVariable(std::wstring name, const std::wstring& value) + : name_(std::move(name)) { + const DWORD needed = GetEnvironmentVariableW(name_.c_str(), nullptr, 0); + if (needed != 0) { + std::wstring old(needed, L'\0'); + const DWORD written = GetEnvironmentVariableW(name_.c_str(), old.data(), needed); + require(written < needed, "test environment variable changed during inspection"); + old.resize(written); + previous_ = std::move(old); + } + require(SetEnvironmentVariableW(name_.c_str(), value.c_str()) != FALSE, + "could not set a process-local test environment variable"); + } + ~EnvironmentVariable() noexcept { + SetEnvironmentVariableW(name_.c_str(), previous_ ? previous_->c_str() : nullptr); + } + EnvironmentVariable(const EnvironmentVariable&) = delete; + EnvironmentVariable& operator=(const EnvironmentVariable&) = delete; + EnvironmentVariable(EnvironmentVariable&&) = delete; + EnvironmentVariable& operator=(EnvironmentVariable&&) = delete; +private: + std::wstring name_; + std::optional previous_; +}; + +void check_registry(const fs::path& root) { + RegistryTree tree; + const RegistryOverride isolated(tree.get()); + expect_failure([] { static_cast(steamfix::steam::registry_string(L"SteamExe")); }, + "missing Steam registry key was accepted"); + const TestRegistryKey key = create_key(tree.get(), L"Software\\Valve\\Steam"); + const auto put = [&](const wchar_t* name, const std::wstring_view value, const DWORD type = REG_SZ) { + require(value.size() < std::numeric_limits::max() / sizeof(wchar_t), "test registry value too large"); + check_status(RegSetKeyValueW(key.get(), nullptr, name, type, value.data(), + static_cast(value.size() * sizeof(wchar_t)))); + }; + expect_failure([] { static_cast(steamfix::steam::registry_string(L"SteamExe")); }, + "missing Steam registry value was accepted"); + put(L"SteamExe", L"C:\\Steam\\steam.exe"); + require(steamfix::steam::registry_string(L"SteamExe") == L"C:\\Steam\\steam.exe", + "nonterminated registry string must be read safely"); + constexpr std::wstring_view padded{L"C:\\Steam\0\0", std::size(L"C:\\Steam\0\0") - 1U}; + put(L"SteamPath", padded); + require(steamfix::steam::registry_string(L"SteamPath") == L"C:\\Steam", "trailing registry NULs must be trimmed"); + constexpr std::wstring_view embedded{L"C:\\Steam\0hidden", std::size(L"C:\\Steam\0hidden") - 1U}; + put(L"SteamExe", embedded); + expect_failure([] { static_cast(steamfix::steam::registry_string(L"SteamExe")); }, + "embedded registry NUL was accepted"); + put(L"SteamExe", L""); + expect_failure([] { static_cast(steamfix::steam::registry_string(L"SteamExe")); }, + "empty registry string was accepted"); + const std::wstring oversized(70U * 1024U, L'x'); + put(L"SteamExe", oversized); + expect_failure([] { static_cast(steamfix::steam::registry_string(L"SteamExe")); }, + "oversized registry string was accepted"); + const DWORD integer = 1; + check_status(RegSetKeyValueW(key.get(), nullptr, L"SteamExe", REG_DWORD, &integer, sizeof(integer))); + expect_failure([] { static_cast(steamfix::steam::registry_string(L"SteamExe")); }, + "wrong registry value type was accepted"); + constexpr std::array malformed{65, 0, 66}; + check_status(RegSetKeyValueW(key.get(), nullptr, L"SteamExe", REG_SZ, malformed.data(), + static_cast(malformed.size()))); + expect_failure([] { static_cast(steamfix::steam::registry_string(L"SteamExe")); }, + "odd-byte registry string was accepted"); + const std::wstring variable = L"D2STEAMFIX_TEST_PATH_" + std::to_wstring(GetCurrentProcessId()); + const EnvironmentVariable environment(variable, (root / L"Steam").native()); + put(L"SteamPath", L"%" + variable + L"%", REG_EXPAND_SZ); + require(steamfix::steam::registry_string(L"SteamPath") == (root / L"Steam").native(), + "expandable registry path must expand process environment variables"); + put(L"SteamExe", (root / L"Steam" / L"steam.exe").native()); + put(L"SteamPath", (root / L"Steam").native()); + require(steamfix::steam::discover() == fs::canonical(root / L"Steam"), "Steam registry executable discovery failed"); + put(L"SteamExe", (root / L"does-not-exist.exe").native()); + require(steamfix::steam::discover() == fs::canonical(root / L"Steam"), "Steam registry path fallback failed"); + put(L"SteamPath", (root / L"does-not-exist").native()); + expect_failure([] { static_cast(steamfix::steam::discover()); }, + "Steam discovery accepted invalid registry locations"); +} + +void deny_execute(const HANDLE file) { + PSECURITY_DESCRIPTOR raw = nullptr; + require(ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(D;;0x20;;;WD)(A;;GA;;;WD)", SDDL_REVISION_1, &raw, nullptr) != FALSE, + "could not create a test-only renderer ACL"); + const steamfix::LocalMemory descriptor(raw); + BOOL present = FALSE; + BOOL defaulted = FALSE; + PACL acl = nullptr; + require(GetSecurityDescriptorDacl(raw, &present, &acl, &defaulted) != FALSE && present, + "could not read test-only renderer ACL"); + check_status(static_cast(SetSecurityInfo(file, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + nullptr, nullptr, acl, nullptr))); +} + +void check_repair_files(const fs::path& root, const steamfix::Logger& log) { + using namespace steamfix::repair; + require(missing(ERROR_FILE_NOT_FOUND) && missing(ERROR_PATH_NOT_FOUND) && !missing(ERROR_ACCESS_DENIED), + "missing-file classification is incorrect"); + const fs::path directory = root / L"renderers"; + fs::create_directory(directory); + require(!open_existing(directory / L"absent.dll", READ_CONTROL), "missing renderer must be optional"); + require(!needed(directory, log), "empty renderer directory cannot require permission repair"); + const fs::path renderer64 = directory / renderer_names.front(); + const fs::path renderer32 = directory / renderer_names.back(); + write_file(renderer64); + auto file = open_existing(renderer64, READ_CONTROL | WRITE_DAC); + require(file.has_value(), "regular renderer was not opened"); + require_regular(file->get(), renderer64); + const UniqueHandle token = current_token(); + require(!token_user(token.get()).empty(), "token-user information was not returned"); + require(same_user(token.get(), token.get()), "same token must identify the same user"); + require(access_allowed(file->get(), token.get(), FILE_GENERIC_READ | FILE_GENERIC_EXECUTE), + "new renderer should permit current-user read and execute"); + expect_failure([&] { static_cast(access_allowed(file->get(), nullptr, FILE_EXECUTE)); }, + "invalid access-check token was accepted"); + expect_failure([&] { static_cast(access_allowed(file->get(), token.get(), GENERIC_READ)); }, + "unmapped generic access request was accepted"); + require(!needed(directory, log), "usable renderer should not require repair"); + verify_repaired(directory, false, log); + expect_failure([&] { verify_repaired(directory, true, log); }, + "repair accepted a missing previously-present 32-bit renderer"); + write_file(renderer32); + verify_repaired(directory, true, log); + deny_execute(file->get()); + require(!access_allowed(file->get(), token.get(), FILE_EXECUTE), "explicit execute denial must be enforced"); + require(access_allowed(file->get(), token.get(), FILE_GENERIC_READ), "execute denial must preserve reading"); + require(needed(directory, log), "execute-denied renderer must require repair"); + expect_failure([&] { verify_repaired(directory, true, log); }, + "repair accepted an execute-denied renderer"); + expect_failure([] { static_cast(token_user(nullptr)); }, "invalid token was accepted"); + for (const HANDLE invalid : std::array{nullptr, INVALID_HANDLE_VALUE}) { + expect_windows_failure([&] { static_cast(access_allowed(invalid, token.get(), FILE_EXECUTE)); }, + ERROR_INVALID_HANDLE, "invalid access-check file did not report ERROR_INVALID_HANDLE"); + } + expect_failure([&] { require_regular(INVALID_HANDLE_VALUE, renderer64); }, + "invalid regular-file handle was accepted"); + const UniqueHandle folder(CreateFileW(directory.c_str(), FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, nullptr)); + expect_failure([&] { require_regular(folder.get(), directory); }, + "directory was accepted as a renderer file"); + const fs::path hardlink = directory / L"linked.dll"; + fs::create_hard_link(renderer32, hardlink); + expect_failure([&] { static_cast(open_existing(renderer32, READ_CONTROL)); }, + "multiply-linked renderer was accepted"); + const fs::path symlink = directory / L"reparse.dll"; + if (CreateSymbolicLinkW(symlink.c_str(), renderer32.c_str(), + SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE) != FALSE) { + const UniqueHandle link(CreateFileW(symlink.c_str(), FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + expect_failure([&] { require_regular(link.get(), symlink); }, + "reparse-point renderer was accepted"); + fs::remove(symlink); + } else { + std::cout << "Symbolic-link rejection requires Windows symbolic-link creation permission.\n"; + } + expect_failure([&] { static_cast(remove_renderers(directory, log)); }, + "multiply-linked renderer deletion was accepted"); + require(fs::exists(renderer64), "renderer deletion started before both handles were validated"); + fs::remove(hardlink); + file.reset(); + require(remove_renderers(directory, log), "existing 32-bit renderer must be recorded"); + require(!fs::exists(renderer64) && !fs::exists(renderer32), "renderer handles did not delete their files"); + require(!remove_renderers(directory, log), "absent 32-bit renderer must not be recorded"); + expect_failure([&] { verify_repaired(directory, false, log); }, + "repair accepted a missing required 64-bit renderer"); + write_file(renderer64); + require(!remove_renderers(directory, log), "64-bit-only renderer deletion result was incorrect"); + const fs::path recovery = directory / L"steamfix.dat"; + write_file(recovery); + remove_file(recovery, log); + require(!fs::exists(recovery), "repair recovery-file deletion failed"); + remove_file(recovery, log); +} + +void check_mutexes() { + using steamfix::repair::MutexLock; + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_move_constructible_v); + static_assert(std::is_nothrow_destructible_v); + const std::wstring name = L"Local\\D2SteamFixTests-Mutex-" + std::to_wstring(GetCurrentProcessId()); + const UniqueHandle ready(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + const UniqueHandle release(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + std::exception_ptr failure; + std::jthread owner([&] { + try { + const MutexLock lock(name.c_str(), 0); + SetEvent(ready.get()); + require(WaitForSingleObject(release.get(), 10000) == WAIT_OBJECT_0, "mutex test release timed out"); + } catch (...) { + failure = std::current_exception(); + SetEvent(ready.get()); + } + }); + try { + require(WaitForSingleObject(ready.get(), 5000) == WAIT_OBJECT_0, "mutex owner did not start"); + expect_failure([&] { const MutexLock contender(name.c_str(), 0); }, + "contended repair mutex did not time out"); + } catch (...) { + SetEvent(release.get()); + owner.join(); + throw; + } + SetEvent(release.get()); + owner.join(); + if (failure) std::rethrow_exception(failure); + { + const MutexLock reacquired(name.c_str(), 0); + } + + const UniqueHandle abandoned(CreateMutexW(nullptr, FALSE, name.c_str())); + bool acquired = false; + std::jthread abandoning_owner([&] { + acquired = WaitForSingleObject(abandoned.get(), 5000) == WAIT_OBJECT_0; + // Thread exit deliberately abandons this test-owned mutex. + }); + abandoning_owner.join(); + require(acquired, "test thread could not acquire its own mutex"); + const MutexLock recovered(name.c_str(), 0); +} + +void check_processes(const fs::path& root, const steamfix::Logger& log) { + using namespace steamfix::process; + const fs::path self = image_path(GetCurrentProcess()); + require(fs::is_regular_file(self), "queried process image must exist"); + require(creation_key(GetCurrentProcess()) != 0, "process creation key must not be empty"); + const UniqueHandle query = open_query(GetCurrentProcessId()); + require(steamfix::paths::equal_windows(image_path(query.get()), self), "opened process image differs from self"); + const auto processes = snapshot(); + require(std::ranges::any_of(processes, [](const ProcessInfo& item) { return item.pid == GetCurrentProcessId(); }), + "process snapshot omitted its caller"); + const auto parents = parent_chain(); + require(!parents.empty() && parents.size() <= 8U, "process parent chain is empty or exceeds its bound"); + require(std::ranges::find(parents, GetCurrentProcessId()) == parents.end(), "process parent chain includes self"); + require(process_name_is(L"DESTINY2.EXE", L"destiny2.exe"), "process name comparison must ignore case"); + require(!process_name_is(L"destiny2.exe.old", L"destiny2.exe"), "process name must match the whole value"); + require(process_message("start", L"destiny2.exe", 42).find("pid=42") != std::string::npos, + "process diagnostic omitted its identity"); + expect_failure([] { static_cast(open_query(0)); }, "invalid process ID was accepted"); + expect_failure([] { static_cast(image_path(nullptr)); }, "invalid image-query handle was accepted"); + expect_failure([] { static_cast(creation_key(nullptr)); }, "invalid creation-time handle was accepted"); + expect_failure([] { static_cast(steamfix::repair::wait_for_process(nullptr, 0)); }, + "invalid process wait handle was accepted"); + require(matching_in_dir(root).empty(), "isolated directory unexpectedly contains a Destiny process"); + + const fs::path launcher = root / L"destiny2launcher.exe"; + const fs::path game = root / L"destiny2.exe"; + fs::copy_file(self, launcher); + fs::copy_file(self, game); + { + const OwnedChild launcher_child(launcher); + const OwnedChild game_child(game); + const auto matches = matching_in_dir(root); + require(matches.size() == 2, "both test-owned Destiny categories must be discovered"); + require(std::ranges::any_of(matches, [&](const auto& item) { return item.first == launcher_child.pid(); }), + "test-owned launcher identity was not preserved"); + require(std::ranges::any_of(matches, [&](const auto& item) { return item.first == game_child.pid(); }), + "test-owned game identity was not preserved"); + require(matching_in_dir(root / L"different").empty(), "Destiny matching ignored executable directory"); + } + const fs::path steam = root / L"steam.exe"; + fs::copy_file(self, steam); + { + OwnedChild child(steam); + require(steamfix::repair::wait_for_process(child.get(), 0) == WAIT_TIMEOUT, + "suspended child must remain alive"); + require(steamfix::repair::steam_client_alive(root), "test-owned Steam process was not discovered"); + require(!steamfix::repair::steam_client_alive(root / L"different"), "Steam liveness ignored installation directory"); + child.finish(37); + require(steamfix::repair::wait_for_process(child.get(), 0) == WAIT_OBJECT_0, + "finished child must signal its process handle"); + steamfix::repair::wait_for_repair(child.get(), root / L"different", log); + } + require(!steamfix::repair::steam_client_alive(root), "finished Steam child remained alive"); + expect_failure([&] { steamfix::repair::wait_for_repair(nullptr, root, log); }, + "repair accepted an invalid child wait handle"); + require(!steamfix::overlay::loaded_renderer(), "isolated test executable unexpectedly loaded a Steam overlay"); +} + +struct FreeModule { + void operator()(HMODULE module) const noexcept { FreeLibrary(module); } +}; + +void check_overlay_modules(const fs::path& root) { + const fs::path fixture = steamfix::process::image_path(GetCurrentProcess()).parent_path() / + L"overlay_fixture.dll"; + require(fs::is_regular_file(fixture), "overlay test fixture DLL is missing beside the test executable"); + for (const auto* name : {L"GameOverlayRenderer64.dll", L"GameOverlayRenderer.dll"}) { + const fs::path copy = root / name; + fs::copy_file(fixture, copy); + { + const std::unique_ptr, FreeModule> loaded(LoadLibraryW(copy.c_str())); + require(loaded != nullptr, "could not load the test-owned overlay fixture DLL"); + const auto found = steamfix::overlay::loaded_renderer(); + require(found && steamfix::paths::equal_windows(*found, copy), + "loaded renderer detection did not identify the test-owned module"); + } + require(!steamfix::overlay::loaded_renderer(), "released overlay fixture remained loaded"); + } +} + +} // namespace + +int main() { + try { + TemporaryDirectory temporary; + const steamfix::Logger log(temporary.path() / L"steamfix.log"); + check_release_state(); + check_paths_and_installations(temporary.path()); + check_registry(temporary.path()); + check_repair_files(temporary.path(), log); + check_mutexes(); + check_processes(temporary.path(), log); + check_overlay_modules(temporary.path()); + std::cout << "Runtime safety, Steam discovery, process, repair, and overlay checks passed.\n"; + } catch (const std::exception& error) { + std::cerr << "Runtime integration test failed: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/tests/ui_entry_tests.cpp b/tests/ui_entry_tests.cpp new file mode 100644 index 0000000..1405340 --- /dev/null +++ b/tests/ui_entry_tests.cpp @@ -0,0 +1,328 @@ +#include "steamfix/command_line.hpp" +#include "steamfix/ui.hpp" +#include "steamfix/win32.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; +using steamfix::UniqueHandle; + +constexpr int skipped = 77; +constexpr std::wstring_view information_text = L"D2SteamFix test information: 淇京 馃幃"; +constexpr std::wstring_view error_text = L"D2SteamFix test error: 淇京 馃幃"; + +class DesktopUnavailable final : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +enum class DialogFailure { exited_without_dialog, deadline }; + +class DialogExpectationFailure final : public std::runtime_error { +public: + explicit DialogExpectationFailure(const DialogFailure reason) + : std::runtime_error(reason == DialogFailure::exited_without_dialog + ? "child exited without its expected D2SteamFix dialog" + : "child did not display its expected D2SteamFix dialog before the deadline"), + reason_(reason) {} + [[nodiscard]] DialogFailure reason() const noexcept { return reason_; } +private: + DialogFailure reason_; +}; + +void require(const bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +[[noreturn]] void throw_windows_error() { + throw std::system_error(steamfix::windows_exit_status(GetLastError()), std::system_category()); +} + +[[noreturn]] void desktop_probe_error(const char* operation) { + const DWORD error = GetLastError(); + if (error == ERROR_ACCESS_DENIED || error == ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION) { + throw DesktopUnavailable(operation); + } + throw std::system_error(steamfix::windows_exit_status(error == ERROR_SUCCESS ? ERROR_GEN_FAILURE : error), + std::system_category(), operation); +} + +struct DestroyProbeWindow { + void operator()(const HWND window) const noexcept { DestroyWindow(window); } +}; + +struct ProbeSearch { + HWND expected; + bool found = false; +}; + +BOOL CALLBACK find_probe_window(const HWND window, const LPARAM context) noexcept { + auto& search = *reinterpret_cast(context); + if (window == search.expected) search.found = true; + return TRUE; +} + +void verify_desktop() { + // These desktop/station handles are borrowed; Windows owns their lifetime. + SetLastError(ERROR_SUCCESS); + if (GetProcessWindowStation() == nullptr) desktop_probe_error("cannot access the process window station"); + SetLastError(ERROR_SUCCESS); + if (GetThreadDesktop(GetCurrentThreadId()) == nullptr) desktop_probe_error("cannot access the thread desktop"); + const HINSTANCE instance = GetModuleHandleW(nullptr); + if (instance == nullptr) throw_windows_error(); + SetLastError(ERROR_SUCCESS); + // This native probe is independent of production dialogs. A hidden top-level + // window supports enumeration on accessible noninteractive desktops too. + const std::unique_ptr, DestroyProbeWindow> probe( + CreateWindowExW(0, L"STATIC", L"D2SteamFix test desktop probe", WS_POPUP, + 0, 0, 1, 1, nullptr, nullptr, instance, nullptr)); + if (!probe) desktop_probe_error("cannot create a native desktop probe window"); + ProbeSearch search{probe.get()}; + SetLastError(ERROR_SUCCESS); + if (!EnumWindows(find_probe_window, reinterpret_cast(&search))) { + desktop_probe_error("cannot enumerate the native desktop probe window"); + } + require(search.found, "desktop enumeration omitted the test-owned native probe window"); +} + +fs::path current_executable() { + std::wstring text(32768, L'\0'); + const DWORD written = GetModuleFileNameW(nullptr, text.data(), static_cast(text.size())); + if (written == 0) throw_windows_error(); + require(written < text.size(), "test executable path exceeded the Windows path limit"); + text.resize(written); + return fs::path(text); +} + +class ChildProcess { +public: + ChildProcess(const fs::path& executable, const std::wstring_view argument) { + std::wstring line; + steamfix::command_line::append_quoted(line, executable.native()); + steamfix::command_line::append_quoted(line, argument); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + if (!CreateProcessW(executable.c_str(), line.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, executable.parent_path().c_str(), + &startup, &information)) { + throw_windows_error(); + } + process_ = UniqueHandle(information.hProcess); + thread_ = UniqueHandle(information.hThread); + pid_ = information.dwProcessId; + } + ~ChildProcess() noexcept { + if (process_.get() != nullptr && WaitForSingleObject(process_.get(), 0) == WAIT_TIMEOUT) { + // This process handle is owned by the immediately preceding + // CreateProcess call; no enumerated process is ever terminated. + TerminateProcess(process_.get(), 1); + WaitForSingleObject(process_.get(), 5000); + } + } + ChildProcess(const ChildProcess&) = delete; + ChildProcess& operator=(const ChildProcess&) = delete; + ChildProcess(ChildProcess&&) = delete; + ChildProcess& operator=(ChildProcess&&) = delete; + [[nodiscard]] HANDLE get() const noexcept { return process_.get(); } + [[nodiscard]] DWORD pid() const noexcept { return pid_; } + [[nodiscard]] DWORD exit_status() const { + DWORD status = 0; + if (!GetExitCodeProcess(process_.get(), &status)) throw_windows_error(); + return status; + } +private: + UniqueHandle process_; + UniqueHandle thread_; + DWORD pid_ = 0; +}; + +bool is_owned_dialog(const HWND window, const DWORD pid) noexcept { + DWORD owner = 0; + GetWindowThreadProcessId(window, &owner); + if (owner != pid) return false; + std::array class_name{}; + std::array title{}; + if (GetClassNameW(window, class_name.data(), static_cast(class_name.size())) == 0) return false; + if (GetWindowTextW(window, title.data(), static_cast(title.size())) == 0) return false; + return std::wstring_view(class_name.data()) == L"#32770" && + std::wstring_view(title.data()) == L"D2SteamFix"; +} + +struct DialogSearch { + DWORD pid; + HWND found = nullptr; +}; + +BOOL CALLBACK find_dialog(const HWND window, const LPARAM context) noexcept { + // Win32 enumeration callbacks carry a borrowed context through LPARAM. + auto& search = *reinterpret_cast(context); + DWORD owner = 0; + GetWindowThreadProcessId(window, &owner); + if (owner != search.pid) return TRUE; + if (!is_owned_dialog(window, search.pid)) return TRUE; + search.found = window; + return FALSE; +} + +HWND wait_for_dialog(const ChildProcess& child, const DWORD expected_exit, + const std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + DialogSearch search{child.pid()}; + const BOOL enumerated = EnumWindows(find_dialog, reinterpret_cast(&search)); + if (search.found != nullptr) return search.found; + if (!enumerated) throw std::runtime_error("Windows could not enumerate the previously verified desktop"); + const DWORD wait = WaitForSingleObject(child.get(), 0); + if (wait == WAIT_FAILED) throw_windows_error(); + if (wait == WAIT_OBJECT_0) { + require(child.exit_status() == expected_exit, "dialog child exited with an unexpected status"); + throw DialogExpectationFailure(DialogFailure::exited_without_dialog); + } + Sleep(50); + } + throw DialogExpectationFailure(DialogFailure::deadline); +} + +struct TextSearch { + std::wstring_view expected; + bool found = false; +}; + +BOOL CALLBACK find_static_text(const HWND control, const LPARAM context) noexcept { + auto& search = *reinterpret_cast(context); + std::array class_name{}; + if (GetClassNameW(control, class_name.data(), static_cast(class_name.size())) == 0 || + std::wstring_view(class_name.data()) != L"Static") return TRUE; + if ((GetWindowLongPtrW(control, GWL_STYLE) & SS_TYPEMASK) == SS_ICON) return TRUE; + std::array text{}; + DWORD_PTR length = 0; + // GetWindowText cannot reliably read controls in another process. WM_GETTEXT + // is a system message whose buffer Windows marshals across the boundary. + if (SendMessageTimeoutW(control, WM_GETTEXT, static_cast(text.size()), + reinterpret_cast(text.data()), SMTO_ABORTIFHUNG | SMTO_BLOCK, + 500, &length) == 0) return TRUE; + if (length > 0 && length < text.size() && + std::wstring_view(text.data(), static_cast(length)).find(search.expected) != + std::wstring_view::npos) { + search.found = true; + return FALSE; + } + return TRUE; +} + +void inspect_and_close(const HWND dialog, const ChildProcess& child, + const std::wstring_view expected_text, const DWORD expected_exit) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + bool found = false; + do { + require(is_owned_dialog(dialog, child.pid()), "test-owned dialog disappeared before inspection"); + TextSearch text{expected_text}; + EnumChildWindows(dialog, find_static_text, reinterpret_cast(&text)); + // EnumWindows can see MessageBox's dialog before initialization has + // created its controls and shown it. Wait for the visible modal dialog + // before sending a close request. + found = text.found && IsWindowVisible(dialog) && IsWindowEnabled(dialog); + if (!found) Sleep(25); + } while (!found && std::chrono::steady_clock::now() < deadline); + require(found, "D2SteamFix dialog did not contain its expected message"); + require(is_owned_dialog(dialog, child.pid()), "dialog ownership changed before closing"); + DWORD_PTR close_result = 0; + // An MB_OK message box supports its normal window close action. Sending it + // directly avoids dependence on native button IDs or foreground focus. + const LRESULT closed = SendMessageTimeoutW(dialog, WM_CLOSE, 0, 0, + SMTO_ABORTIFHUNG | SMTO_BLOCK, 2000, &close_result); + const DWORD wait = WaitForSingleObject(child.get(), 5000); + if (wait == WAIT_FAILED) throw_windows_error(); + require(closed != 0 || wait == WAIT_OBJECT_0, "owned dialog did not accept its close request"); + require(wait == WAIT_OBJECT_0, "dialog child did not exit after its close request"); + require(child.exit_status() == expected_exit, "dialog child returned an unexpected exit status"); +} + +void check_dialog(const fs::path& executable, const std::wstring_view argument, + const std::wstring_view expected_text, const DWORD expected_exit) { + const ChildProcess child(executable, argument); + const HWND dialog = wait_for_dialog(child, expected_exit, std::chrono::seconds(8)); + inspect_and_close(dialog, child, expected_text, expected_exit); +} + +void check_missing_dialog_failure(const fs::path& executable, const std::wstring_view argument, + const DialogFailure expected, const std::chrono::milliseconds timeout) { + const ChildProcess child(executable, argument); + try { + static_cast(wait_for_dialog(child, 0, timeout)); + } catch (const DialogExpectationFailure& error) { + require(error.reason() == expected, "a no-dialog child failed for the wrong reason"); + return; + } catch (const DesktopUnavailable&) { + // A future regression must fail the parent suite rather than reach its + // desktop-skip handler. Other setup/API errors propagate as failures. + throw std::runtime_error("a missing or hung dialog was skipped instead of failing"); + } + throw std::runtime_error("a no-dialog child unexpectedly displayed a D2SteamFix dialog"); +} + +struct CommandLineDeleter { + void operator()(wchar_t** arguments) const noexcept { LocalFree(arguments); } +}; + +} // namespace + +int main() { + try { + int count = 0; + const std::unique_ptr arguments(CommandLineToArgvW(GetCommandLineW(), &count)); + if (!arguments || count != 2) { + throw std::runtime_error("UI tests require the built steamfix executable path"); + } + const std::span parsed{arguments.get(), static_cast(count)}; + const std::wstring_view argument = parsed[1]; + if (argument == L"--no-dialog") return 0; + if (argument == L"--idle-before-dialog") { + Sleep(30000); + return 0; + } + if (argument == L"--show-info") { + steamfix::ui::info(information_text); + return 0; + } + if (argument == L"--show-error") { + steamfix::ui::error(error_text); + return 0; + } + const fs::path self = current_executable(); + const fs::path executable(argument); + require(fs::is_regular_file(executable), "built steamfix executable is missing"); + verify_desktop(); + check_missing_dialog_failure(self, L"--no-dialog", DialogFailure::exited_without_dialog, + std::chrono::seconds(3)); + check_missing_dialog_failure(self, L"--idle-before-dialog", DialogFailure::deadline, + std::chrono::milliseconds(500)); + check_dialog(self, L"--show-info", information_text, 0); + check_dialog(self, L"--show-error", error_text, 0); + check_dialog(executable, L"D2SteamFix-test-invalid-forwarded.exe", + L"the forwarded executable must be destiny2launcher.exe", 12); + std::cout << "Real information/error dialogs and steamfix validation entry passed.\n"; + } catch (const DesktopUnavailable& error) { + std::cout << "SKIP: UI/entry dialog tests: " << error.what() << '\n'; + return skipped; + } catch (const std::exception& error) { + std::cerr << "UI/entry integration test failed: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/tests/win32_tests.cpp b/tests/win32_tests.cpp new file mode 100644 index 0000000..06f350e --- /dev/null +++ b/tests/win32_tests.cpp @@ -0,0 +1,132 @@ +#include "steamfix/win32.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +static_assert(!std::is_copy_constructible_v); +static_assert(!std::is_copy_assignable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_destructible_v); +static_assert(!std::is_copy_constructible_v); +static_assert(!std::is_copy_assignable_v); +static_assert(!std::is_move_constructible_v); +static_assert(!std::is_move_assignable_v); +static_assert(std::is_nothrow_destructible_v); + +bool is_open(HANDLE handle) noexcept { + DWORD flags{}; + return GetHandleInformation(handle, &flags) != FALSE; +} + +bool check_handle_ownership() { + HANDLE observed{}; + { + steamfix::UniqueHandle original{CreateEventW(nullptr, TRUE, FALSE, nullptr)}; + observed = original.get(); + steamfix::UniqueHandle moved{std::move(original)}; + // C.64: UniqueHandle promises an empty source after a move. Verify that + // contract; this observation does not use the transferred resource. +#ifdef _MSC_VER +#pragma warning(suppress: 26800) +#endif + if (original.get() != nullptr || moved.get() != observed || !is_open(observed)) { + std::cerr << "Moving a handle did not transfer ownership.\n"; + return false; + } + + moved.reset(moved.get()); + if (!is_open(observed)) { + std::cerr << "Resetting to the current handle closed it.\n"; + return false; + } + + steamfix::UniqueHandle assigned{CreateEventW(nullptr, TRUE, FALSE, nullptr)}; + const HANDLE replaced = assigned.get(); + assigned = std::move(moved); + // Inspect the explicitly supported empty moved-from state (C.64). +#ifdef _MSC_VER +#pragma warning(suppress: 26800) +#endif + if (moved.get() != nullptr || assigned.get() != observed || is_open(replaced)) { + std::cerr << "Move assignment did not close the replaced handle.\n"; + return false; + } + } + if (is_open(observed)) { + std::cerr << "Destruction did not close the owned handle.\n"; + return false; + } + + steamfix::UniqueHandle released{CreateEventW(nullptr, TRUE, FALSE, nullptr)}; + steamfix::UniqueHandle recipient{released.release()}; + if (released.get() != nullptr || !is_open(recipient.get())) { + std::cerr << "Release did not leave the transferred handle open.\n"; + return false; + } + const HANDLE reset = recipient.get(); + recipient.reset(); + if (recipient.get() != nullptr || is_open(reset)) { + std::cerr << "Reset did not close and clear the owned handle.\n"; + return false; + } + return true; +} + +bool check_text_conversion() { + constexpr std::wstring_view wide{L"淇京 馃幃\0tail", std::size(L"淇京 馃幃\0tail") - 1U}; + constexpr std::string_view utf8{"淇京 馃幃\0tail", std::size("淇京 馃幃\0tail") - 1U}; + if (steamfix::narrow(wide) != utf8 || steamfix::widen(utf8) != wide || + !steamfix::narrow(L"").empty() || !steamfix::widen("").empty()) { + std::cerr << "Text conversion changed UTF-8, embedded NULs, or empty input.\n"; + return false; + } + // Diagnostics must remain printable even when a native message contains an + // isolated surrogate or bytes from the current ANSI code page. + if (steamfix::narrow(L"\xD800") != "\xEF\xBF\xBD") { + std::cerr << "An isolated UTF-16 surrogate was not replaced safely.\n"; + return false; + } + constexpr std::string_view invalid_utf8{"\xFF"}; + std::array ansi{}; + const int count = MultiByteToWideChar(CP_ACP, 0, invalid_utf8.data(), + static_cast(invalid_utf8.size()), ansi.data(), + static_cast(ansi.size())); + if (count <= 0 || steamfix::widen(invalid_utf8) != + std::wstring_view(ansi.data(), static_cast(count))) { + std::cerr << "Invalid UTF-8 did not fall back to the current ANSI code page.\n"; + return false; + } + return true; +} + +bool check_exit_status() noexcept { + return steamfix::windows_exit_status(DWORD{0}) == 0 && + steamfix::windows_exit_status(DWORD{259}) == 259 && + steamfix::windows_exit_status(DWORD{0xffffffffUL}) == -1 && + steamfix::windows_exit_status(DWORD{0x80000000UL}) == std::numeric_limits::min(); +} + +} // namespace + +int main() { + try { + if (!check_handle_ownership() || !check_text_conversion()) return 1; + if (!check_exit_status()) { + std::cerr << "Windows exit status lost its 32-bit representation.\n"; + return 1; + } + } catch (const std::exception& error) { + std::cerr << "Win32 helper regression test failed: " << error.what() << '\n'; + return 1; + } + return 0; +}