From f25ac8226aee54d4025b3861b83b1c755aaf3c92 Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 19:08:44 +0000 Subject: [PATCH 01/12] Strengthen Core Guidelines safety and Windows regression coverage for 1.1.1 --- .github/workflows/build.yml | 26 +- .gitignore | 4 +- AGENTS.md | 2 +- CMakeLists.txt | 89 +++- cmake/CoreGuidelines.ruleset | 21 + cmake/RunCoverage.ps1 | 60 +++ cmake/RunCoverageTests.cmake | 10 + cmake/SummarizeCoverage.py | 91 +++++ cmake/coverage_inventory.cpp | 6 + docs/BUILDING.md | 83 +++- docs/CPP_CORE_GUIDELINES.md | 145 +++++++ docs/CPP_CORE_GUIDELINES_PLAN.md | 91 +++++ include/steamfix/acl.hpp | 8 +- include/steamfix/app.hpp | 2 +- include/steamfix/elevation.hpp | 36 +- include/steamfix/error.hpp | 24 +- include/steamfix/win32.hpp | 30 +- make.cmd | 37 +- src/acl.cpp | 181 +++++---- src/app.cpp | 51 ++- src/command_line.cpp | 4 +- src/elevation.cpp | 212 ++++++---- src/logging.cpp | 15 +- src/overlay.cpp | 11 +- src/paths.cpp | 8 +- src/process.cpp | 29 +- src/repair.cpp | 76 +++- src/steam.cpp | 25 +- tests/acl_tests.cpp | 678 +++++++++++++++++++++++++++++++ tests/app_tests.cpp | 295 ++++++++++++++ tests/command_line_tests.cpp | 99 +++++ tests/elevation_tests.cpp | 410 +++++++++++++++++++ tests/overlay_fixture.cpp | 4 + tests/runtime_tests.cpp | 599 +++++++++++++++++++++++++++ tests/win32_tests.cpp | 107 +++++ 35 files changed, 3301 insertions(+), 268 deletions(-) create mode 100644 cmake/CoreGuidelines.ruleset create mode 100644 cmake/RunCoverage.ps1 create mode 100644 cmake/RunCoverageTests.cmake create mode 100644 cmake/SummarizeCoverage.py create mode 100644 cmake/coverage_inventory.cpp create mode 100644 docs/CPP_CORE_GUIDELINES.md create mode 100644 docs/CPP_CORE_GUIDELINES_PLAN.md create mode 100644 tests/acl_tests.cpp create mode 100644 tests/app_tests.cpp create mode 100644 tests/command_line_tests.cpp create mode 100644 tests/elevation_tests.cpp create mode 100644 tests/overlay_fixture.cpp create mode 100644 tests/runtime_tests.cpp create mode 100644 tests/win32_tests.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aff6857..50cb078 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,33 @@ 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 + if: matrix.configuration == 'Debug' + run: ./cmake/RunCoverage.ps1 -BuildDirectory build + + - name: Upload coverage report + if: always() && matrix.configuration == 'Debug' + 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..aba7d46 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,29 @@ 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 + ) + target_compile_options(${target} PRIVATE /analyze:WX) + 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 +44,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 +72,64 @@ 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}) + add_executable(${target} ${TEST_UNPARSED_ARGUMENTS}) + 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/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) + 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) + + 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}) + 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..2715040 --- /dev/null +++ b/cmake/RunCoverage.ps1 @@ -0,0 +1,60 @@ +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 +$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 +} + +if (Test-Path (Join-Path $outputRoot "coverage.xml")) { + python (Join-Path $PSScriptRoot "SummarizeCoverage.py") ` + (Join-Path $outputRoot "coverage.xml") --source-root $sourceRoot ` + --output (Join-Path $outputRoot "summary.md") + 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..de0177f --- /dev/null +++ b/cmake/RunCoverageTests.cmake @@ -0,0 +1,10 @@ +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..da65440 --- /dev/null +++ b/cmake/SummarizeCoverage.py @@ -0,0 +1,91 @@ +"""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): + files = {} + for item in ET.parse(report).iter("class"): + source = Path(item.attrib["filename"].replace("\\", "/")) + if not source.is_absolute(): + source = source_root / source + try: + relative = source.resolve().relative_to(source_root).as_posix() + except ValueError: + continue + if not relative.startswith(("src/", "include/")): + 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)" + 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) + arguments = parser.parse_args() + summary, complete = summarize(arguments.report, arguments.source_root.resolve()) + 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..7ba675a 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,71 @@ 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. +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. +- Path and Steam-directory validation, process discovery and termination of + fixture children, overlay-module identification, and repair helper behavior. + +Application tests replace dialogs with headless sinks; the real dialog code is +retained in the coverage denominator. The suite does not launch Steam, 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/docs/CPP_CORE_GUIDELINES.md b/docs/CPP_CORE_GUIDELINES.md new file mode 100644 index 0000000..25e4709 --- /dev/null +++ b/docs/CPP_CORE_GUIDELINES.md @@ -0,0 +1,145 @@ +# C++ Core Guidelines review + +This review covers the entire D2SteamFix project at version 1.1.1. The reference +is the supplied `cpp-core-guidelines` skill, with detailed rule lookup against +the official [C++ Core Guidelines](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines). +The skill's main instructions loaded, but its separate reference files were +unavailable from the provider. The fallback is current upstream, rather than a +verified copy of the skill's June 2026 snapshot. + +The review applies guideline reasons, notes, and exceptions to this Windows x64 +C++20 application. It is not a claim that every guideline is mechanically +enforced, that the strict type/bounds/lifetime profiles have been proved, or that +line coverage proves correctness. The Guidelines contain judgment calls and +unfinished rules; a compiler check alone cannot certify complete conformance. + +## Review coverage + +| Files | Areas checked | +| --- | --- | +| `src/acl.cpp`, `include/steamfix/acl.hpp` | Permission invariants, recovery compatibility, descriptor/ACE/SID extents, serialization, encryption, resource cleanup, restoration on failure | +| `src/elevation.cpp`, `include/steamfix/elevation.hpp` | Authenticated helper protocol, cancellation lifetime, const correctness, privileged operations, handle ownership, launch context, error propagation | +| `src/process.cpp`, `include/steamfix/process.hpp` | Process identity, observation reliability, fail-closed monitoring, wait results, signed arithmetic, exit-status representation | +| `src/repair.cpp`, `include/steamfix/repair.hpp` | Consent boundaries, target verification, token storage, wait failures, file lifetime, optional renderer behavior, cleanup ordering | +| `src/steam.cpp`, `src/paths.cpp`, their headers | Registry bounds, string validity, case-insensitive path comparisons, discovery errors, self-contained includes | +| `src/overlay.cpp`, `include/steamfix/overlay.hpp` | Snapshot lifetime, module enumeration errors, path and name validation | +| `src/app.cpp`, `src/main.cpp`, `include/steamfix/app.hpp` | Invocation validation, protected-session lifetime, error recovery, exception boundary, inherited discovery context | +| `src/command_line.cpp`, `include/steamfix/command_line.hpp` | Windows quoting rules, string ownership, direct includes | +| `src/logging.cpp`, `include/steamfix/logging.hpp` | File invariants, ownership, concurrent writes, lock duration, nonthrowing diagnostics | +| `src/ui.cpp`, `include/steamfix/ui.hpp` | Borrowed text, null termination at the API, nonthrowing fallback | +| `include/steamfix/win32.hpp`, `include/steamfix/error.hpp` | Resource wrappers, special members, Unicode sizes, exception copying, process status conversion | +| Build scripts, both workflows, resource/manifest files, repository guidance and documentation | C++20, warning/security settings, version generation, analysis, testing, coverage, consistency with runtime behavior | +| `tests/`, `cmake/` | Fixture isolation, resource/thread cleanup, test linkage, complete coverage denominator, failure visibility | + +## Corrected findings + +**C.21, C.37, C.41, C.49, R.1, R.12, E.6, E.16.** Resource and scope guards now +state their complete copy/move policy. Self-referencing security attributes +cannot be copied or moved. User identity owns independent SID bytes instead of +caching a pointer into a token buffer. Permission probes use resource wrappers. +The failed journal writer closes its exclusive temporary handle before deleting +the file. Protection destruction cannot propagate cleanup exceptions; explicit +restoration continues to report failures and preserves recovery state. + +**E.16, C.64, C.128.** Application and relaunch exceptions hold immutable shared +context so copying an exception does not allocate. Explicit nonthrowing moves +preserve both objects' accessor invariants. Tests check the type traits and +exception capture/rethrow behavior. + +**P.7, ES.65, ES.103, F.24, T.10, Bounds.1/Bounds.3.** ACL readers validate each +ACE's structural bounds and basic allow/deny ACE SID extents before native APIs +inspect the data. Other native ACE forms retain their Windows validation. Byte operations use +validated spans, and binary copying/IPC templates constrain their accepted +types. Permission protection accepts a bounded target view and rejects +duplicate targets before writing a recovery journal. Journal magic values, +layout, encryption, and recovery version remain unchanged. + +**I.2, CP.2, CP.20, R.1.** The authenticated Steam directory is returned to the +coordinator and passed explicitly. Administrator privileges are scoped and +restored; process-token changes are serialized to prevent overlapping +save/restore sequences. Log formatting runs outside the write lock, while each +complete message remains serialized. + +**ES.46, ES.48, ES.50, SL.con.1, P.4.** Private IPC uses const-correct spans and +explicit wire layout without padding disclosure. Rename lengths are checked +before conversion. Token headers are copied into typed locals, the privilege +record has typed aligned storage, and application-owned fixed buffers use +`std::array`. Windows exit-code bits use an explicit same-width `bit_cast` at the +signed application boundary. + +**P.7, E.2, E.12, F.6.** Repair rejects failed or unexpected process waits before +acting. The application has a final nonthrowing exception boundary covering +startup, logging, and even failure-message formatting. Stage-specific errors +and the recovery journal remain available for diagnosis. + +**SF.10, SF.11, Con.1.** Headers and source files include their direct +dependencies. New local bindings are immutable where their values do not +change. The project remains C++20 with warnings as errors and the existing +security/linker settings. + +## Deliberate Windows API boundaries + +I.30 requires containing necessary unsafe operations. Win32 interoperability +still uses `HANDLE`, `HKEY`, `DWORD`, native structures, null-terminated strings, +and borrowed ACL/SID pointers. Their lifetime is held by a resource wrapper or +the surrounding owned storage. Native unsigned fields describe bit masks, +Windows API extents, or the existing serialized format. Signed values are used +for arithmetic counters that are not API extents. + +Some Windows permission and encryption APIs accept mutable pointers for input +they do not modify. One documented adaptation in `acl.cpp` isolates this +constness mismatch, following the ES.50 exception for interfaces without +const-correct input types. Runtime byte representation at these C API +boundaries remains local; strict Type/Bounds profiles are not asserted. + +The helper sends a duplicated handle into its authenticated parent. If response +delivery fails after duplication, the parent can hold an unreported handle +until it exits. Closing that remote value on timeout can race with successful +adoption and invalidate an in-use or recycled handle. Transport failure drops +the connection and propagates an error; Windows reclaims the handle at parent +exit. This retained failure-path limitation is explicitly not claimed as full +RAII coverage of distributed handle transfer. + +The permission broker and privilege mutex have application lifetime, initialized +on first use. They are encapsulated inside the elevation implementation; no +mutable discovery state is exposed. Removing this remaining service lifetime +would require a separate explicit coordinator-context refactor. + +## Verification and coverage + +`make.cmd test` runs the automated suite. `make.cmd analyze` adds the checked-in +Microsoft C++ Core Check ruleset. It enforces selected ownership, special-member, +initialization, and lifetime diagnostics as errors; it does not check every +guideline. The optional full Microsoft ruleset is described in +[BUILDING.md](BUILDING.md). + +Tests exercise actual production implementations and real Windows APIs on +test-owned directories, registry overrides, pipes, mutexes, module fixtures, and +child processes. They include malformed recovery inputs, DPAPI round trips, +permission restoration and interrupted recovery, transport failures, process +monitoring state, quoted/Unicode arguments, and launch failure cleanup. The +application session test starts copies of the test program under the validated +launcher/game names inside its own temporary tree. It never launches real Steam +or Destiny 2. Dialogs in the application test are captured to keep CI +noninteractive; their production implementation still contributes to coverage. + +Debug CI collects OpenCppCoverage HTML and Cobertura reports plus an uncovered +line summary. An inventory executable retains every production translation unit +so code uncalled by tests still contributes to the denominator. Missing source +files make coverage reporting fail. This measures executable-line coverage, +including production headers with emitted code; it is not branch coverage. +Coverage is reported as measured, without excluding difficult code to inflate +the percentage or replacing the user's goal with a lower arbitrary threshold. + +Local verification so far: all production translation units and the initial +tests cross-compile as Windows x64 C++20 with MinGW GCC 14 and warnings as errors; +the application links against the Windows libraries. Portable command-line +expectations pass under GCC. This supplements, and does not replace, the +supported MSVC Debug/Release builds and Windows execution. Final CI and expanded +suite results are recorded in the pull request. + +Live Steam behavior, UAC consent/cancellation, alternate administrator accounts, +and destructive repair against a real installation require the manual Windows +checks in [AGENTS.md](../AGENTS.md). They are not covered by compiling or by +isolated fixtures. Catastrophic OS/API failures and allocation failures also +leave executable error paths that may remain uncovered. diff --git a/docs/CPP_CORE_GUIDELINES_PLAN.md b/docs/CPP_CORE_GUIDELINES_PLAN.md new file mode 100644 index 0000000..7278c46 --- /dev/null +++ b/docs/CPP_CORE_GUIDELINES_PLAN.md @@ -0,0 +1,91 @@ +# Review and strengthen C++ Core Guidelines alignment + + +This ExecPlan is a living document. Maintain Progress, Surprises & Discoveries, Decision Log, and Outcomes & Retrospective as the work proceeds. Follow [AGENTS.md](../AGENTS.md), fetched from newer upstream documentation during the review. No repository PLANS.md was present. + +## Purpose / Big Picture + + +D2SteamFix temporarily changes Steam overlay permissions and restores them when Destiny 2 exits. This review preserves that behavior while making ownership, byte-buffer validation, exception boundaries, and build checks follow the applicable C++ Core Guidelines. The outcome is a version 1.1.1 pull request with review coverage, explicit Windows API exceptions, regression checks, and honest validation limits. A review cannot establish the absence of every possible defect or prove the full lifetime safety profile. + +## Progress + + +- [x] (2026-10-16) Read the C++ Core Guidelines skill and inventoried all tracked project files. +- [x] (2026-10-16) Assigned independent owners for ACL recovery, elevation, runtime operations, and build checks; coordinator owns application and shared support code. +- [x] (2026-10-16) Complete review and fixes across every C++ source/header and build configuration. +- [x] (2026-10-16) Integrate six real-Windows fixture test suites, analyzer checks, and complete-source coverage reporting; all production and test executables cross-compile and link with MinGW warnings as errors. +- [ ] Run supported MSVC Debug/Release builds, Windows regression suites, and production coverage in CI. +- [ ] Record exceptions and validation limits, commit version 1.1.1, and open a pull request. + +## Surprises & Discoveries + + +The skill main file is available, but its referenced rule index and guideline shards fail to load through the skill provider. The official isocpp CppCoreGuidelines document was downloaded to /tmp/CppCoreGuidelines.md for targeted rule lookup. This is a current upstream reference, rather than a verified copy of the skill's June 2026 snapshot. + +The execution host is Linux and initially offers g++ but no Windows/MSVC SDK, MinGW, Wine, CMake, or clang-tidy. A MinGW GCC 14 toolchain and CMake were acquired under /tmp for supplementary compilation. All production sources, six test suites, an inventory executable, and an overlay fixture DLL compile and link. Supported MSVC builds and Windows execution still need CI; live administrator-prompt behavior needs manual Windows verification. + +Initial review identified copyable objects with pointers into their own storage, missing explicit special-member policies, unchecked binary extents, a temporary-journal cleanup ordering issue, and implicit DWORD-to-int exit-status conversion. + +## Decision Log + + +On 2026-10-16, choose a patch release (1.1.1), because the changes correct implementation and validation without adding a user feature. The user explicitly requested a version bump and pull request if changes are made. + +On 2026-10-16, preserve the existing Windows application, permission journal format, and launch/repair behavior. Encapsulate necessary C ABI operations under I.30 instead of introducing an unrelated platform or dependency migration. Windows handles are owned only by dedicated resource wrappers. + +On 2026-10-16, split writers by file ownership in the shared checkout. ACL owner edits acl.cpp/acl.hpp; elevation owner edits elevation.cpp/elevation.hpp; runtime owner edits process, repair, steam, and overlay; build owner edits CMake, workflows, make.cmd, and new tests. Coordinator integrates shared win32 support, entry handling, command-line/path/log/UI code, and documentation. + +On 2026-10-16, the user requested independent cross-review after implementation and test coverage as close to 100% as possible. Expand tests to actual production implementation files and real Windows fixtures. Include every production translation unit in the coverage denominator using an unoptimized inventory executable, fail incomplete reports, and expose uncovered lines. Avoid inventing a percentage before Windows execution or excluding uncalled code to inflate coverage. + +## Outcomes & Retrospective + + +Production review and corrective implementation are complete. Six suites now cover command-line quoting, resource/conversion helpers, application sessions, ACL/recovery, elevation/IPC, and runtime discovery/repair helpers. All binaries cross-compile and link with MinGW warnings as errors. Independent cross-review found and resolved exception-copy safety and an overly broad test deny mask. Supported Windows CI results and remaining coverage gaps will be recorded before delivery. + +## Context and Orientation + + +src/app.cpp validates the Steam invocation and holds the permission protection session while the game runs. src/acl.cpp reads and writes Windows access control lists (ACLs: file permission records) and the recovery journal. src/elevation.cpp requests administrator operations through a helper and named pipe (a Windows process communication channel). src/process.cpp watches verified game processes; src/repair.cpp performs the explicit Steam repair flow. include/steamfix/win32.hpp contains wrappers that release Windows resources automatically. CMakeLists.txt selects C++20 and MSVC; .github/workflows/build.yml builds Windows configurations and release.yml publishes tagged releases. No release tag is created by this task. + +## Plan of Work and Milestones + + +First, read every tracked source/header and supporting build/documentation file. Each owner checks applicable rules, including their reasons and exceptions, and reports findings with rule IDs. Acceptance for this milestone is complete file coverage and concrete findings rather than pattern matching alone. + +Second, fix ownership and lifetime hazards, use bounded views for buffer operations, constrain serialization to appropriate types, preserve Windows exit-code bits explicitly, move inherited launch context out of global state, and ensure exceptions cannot escape the Windows entry point. Keep recovery compatibility and security checks intact. Add focused tests for stable behaviors, including command-line quoting. Acceptance is integrated changes with documented API boundaries and no accidental behavior or journal format changes. + +Third, enable practical compiler analysis in the Windows build, run portable checks locally, and obtain additional Windows compiler evidence if available. Run the Windows CTest suite in Debug and Release in CI. Record which checks ran versus which are configured or pending. Review the final diff, bump version 1.1.1, commit on a dedicated branch, push, and open a pull request targeting the repository default branch. + +## Concrete Steps + + +From /workspace/D2SteamFix, inspect tracked files using git ls-files and rg. Use rg against /tmp/CppCoreGuidelines.md and read only relevant rule sections. Review source changes with git diff --check and git diff. + +Portable command-line expectations pass with g++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -Iinclude tests/command_line_tests.cpp src/command_line.cpp -o /tmp/d2steamfix-command-line-tests followed by running that executable. + +On a 64-bit Windows Visual Studio Developer command prompt, run make.cmd test, make.cmd analyze, and make.cmd coverage. Coverage requires OpenCppCoverage 0.9.9.0 and Python. CI builds Debug/Release with STEAMFIX_ENABLE_CODE_ANALYSIS=ON, runs all six suites, and collects Debug coverage. Reports are written under build/coverage in CI; make.cmd coverage writes build-coverage/coverage locally. See docs/BUILDING.md for complete direct CMake commands. + +## Validation and Acceptance + + +Quoting tests must cover empty arguments, whitespace, embedded quotes, backslash runs before quotes, trailing backslashes, and multiple appended arguments. Where available, Windows CommandLineToArgvW must recover the original arguments. Resource and conversion tests should validate ownership transfer and cleanup behavior without modifying real Steam/game files. + +Debug and Release Windows builds must preserve /W4 /WX and existing hardening. Analyzer findings must be addressed or narrowly justified at the API boundary. A manual Windows launch/repair test must be reported as pending if no Windows runtime is available; compiling or parsing is not evidence of successful UAC or ACL recovery behavior. + +## Idempotence and Recovery + + +All local changes remain in a dedicated branch for review. Tests use temporary data and do not operate on Steam or game files. Preserve existing recovery files and user work. Do not merge, tag, publish a release, or run real repair operations. Repeat read-only checks safely; reconcile remote branch and pull-request state before retrying an uncertain write. + +## Artifacts and Notes + + +The review record will be docs/CPP_CORE_GUIDELINES.md. It will describe file coverage, corrected findings with rule IDs, deliberate C API exceptions, checks, and remaining limitations. The version derives from CMakeLists.txt, including generated Windows version resources and log version text. + +## Interfaces and Dependencies + + +Use C++20 standard facilities, including std::span, std::array, constrained templates, and std::bit_cast where they express existing requirements. Keep existing Windows libraries and APIs. steamfix::windows_exit_status(DWORD) will preserve Windows process status bits as the existing signed application return type. elevation::accept_elevated_launch will return an optional Steam directory, which app.cpp passes explicitly to its launch flow. + +Revision note (2026-10-16): Updated after production fixes, expanded testing, supplementary cross-compilation, and independent cross-review; supported Windows execution and coverage are the remaining validation milestone. diff --git a/include/steamfix/acl.hpp b/include/steamfix/acl.hpp index 6c310f7..81f484f 100644 --- a/include/steamfix/acl.hpp +++ b/include/steamfix/acl.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -21,12 +22,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 +39,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_; diff --git a/include/steamfix/app.hpp b/include/steamfix/app.hpp index 213dddd..56cc4c0 100644 --- a/include/steamfix/app.hpp +++ b/include/steamfix/app.hpp @@ -2,6 +2,6 @@ namespace steamfix::app { -int entry(); +int entry() noexcept; } // namespace steamfix::app diff --git a/include/steamfix/elevation.hpp b/include/steamfix/elevation.hpp index 2ca1766..d6f94c4 100644 --- a/include/steamfix/elevation.hpp +++ b/include/steamfix/elevation.hpp @@ -3,7 +3,10 @@ #include "steamfix/win32.hpp" #include +#include #include +#include +#include #include #include #include @@ -23,20 +26,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); diff --git a/include/steamfix/error.hpp b/include/steamfix/error.hpp index eb0e6fd..129052f 100644 --- a/include/steamfix/error.hpp +++ b/include/steamfix/error.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -21,12 +22,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,9 +42,13 @@ 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; }; diff --git a/include/steamfix/win32.hpp b/include/steamfix/win32.hpp index e9dba17..05c11f4 100644 --- a/include/steamfix/win32.hpp +++ b/include/steamfix/win32.hpp @@ -2,14 +2,24 @@ #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 +28,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 +57,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 +80,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 +88,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 +103,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 +116,7 @@ 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 diff --git a/make.cmd b/make.cmd index afa2cc1..9544962 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,30 @@ call :do_configure || goto failure call :do_build Debug || goto failure goto success +:test +call :do_configure || 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 -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 +78,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 +90,15 @@ if defined help_exit ( ) goto success +:coverage +cmake -S . -B build-coverage -A x64 -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..3b9cda1 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"); @@ -983,12 +1022,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; @@ -1056,13 +1099,13 @@ Protection Protection::apply(const std::vector& targets, fs::path stat try { if (!resuming) { for (Backup& backup : backups) { - if (has_current_user_execute_deny(backup, identity.sid)) { + if (has_current_user_execute_deny(backup, identity.sid())) { 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)); } - backup.denied_acl = make_denied_acl(backup, identity.sid); + backup.denied_acl = make_denied_acl(backup, identity.sid()); } } } catch (const std::exception& error) { @@ -1071,7 +1114,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; @@ -1125,13 +1168,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..650713e 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(); } @@ -381,12 +393,13 @@ Invocation validate_invocation(const std::vector& args, } // 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,9 +424,9 @@ 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; + std::optional log; try { - log = std::make_unique(game_directory / L"steamfix.log"); + log.emplace(game_directory / L"steamfix.log"); } catch (const elevation::Cancelled&) { return 0; } catch (const std::exception& error) { @@ -433,7 +446,7 @@ int entry() { return 0; } const SessionMutex mutex = session_mutex(); - const int code = run(*log, forwarded, recovery); + 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) { @@ -479,6 +492,12 @@ int entry() { L"Destiny 2 directory.\n\nDestiny 2 was not started."); return static_cast(ExitCode::internal); } +} 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..9ce0143 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,24 @@ 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) { + std::span bytes = data; UniqueHandle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); - while (offset < size) { - const DWORD requested = static_cast(std::min(size - offset, MAXDWORD)); + while (!bytes.empty()) { + 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 +114,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); - offset += completed; + if (completed > requested) fail(ERROR_INVALID_DATA); + bytes = bytes.subspan(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 +143,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 +159,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 +174,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,6 +290,7 @@ 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; @@ -275,14 +299,20 @@ UniqueHandle checked_file(const fs::path& path, const DWORD access, const DWORD void rename_without_replacement(const HANDLE file, 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); + if (destination.size() > (MAXDWORD - header_size) / sizeof(wchar_t)) fail(ERROR_BAD_PATHNAME); + const std::size_t name_bytes = destination.size() * sizeof(wchar_t); + FILE_RENAME_INFO rename{}; + rename.ReplaceIfExists = FALSE; + rename.RootDirectory = nullptr; + rename.FileNameLength = static_cast(name_bytes); + std::vector storage(std::max(sizeof(rename), header_size + name_bytes)); + // The Win32 variable-length structure is marshalled as bytes; no C++ + // FILE_RENAME_INFO object is overlaid on vector storage. + std::memcpy(storage.data(), &rename, header_size); + std::ranges::copy(std::as_bytes(std::span{destination}), + std::span{storage}.subspan(header_size, name_bytes).begin()); + if (!SetFileInformationByHandle(file, FileRenameInfo, storage.data(), static_cast(storage.size()))) fail(GetLastError()); } @@ -310,12 +340,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 +360,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 +381,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 +441,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 +461,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 +479,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 +500,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 +526,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 +565,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 +641,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 +778,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 +792,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 +822,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 +852,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..992e3ce 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,14 +105,23 @@ 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) { @@ -115,32 +130,43 @@ bool access_allowed(const HANDLE file, const HANDLE token, const DWORD access) { 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 +234,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 +260,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 +297,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 +328,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,8 +428,7 @@ 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; 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..b91d1ab --- /dev/null +++ b/tests/acl_tests.cpp @@ -0,0 +1,678 @@ +// Keep the production implementation in this translation unit so its private +// binary readers and recovery preflight run unchanged against real Win32 APIs. +#include "../src/acl.cpp" + +#include +#include +#include +#include +#include + +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"); +} + +} // 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(); + 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..0875261 --- /dev/null +++ b/tests/app_tests.cpp @@ -0,0 +1,295 @@ +// Include the implementation to exercise private validation without exposing a +// test-only application API. Link the remaining production modules normally. +#include "../src/app.cpp" + +#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); + 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::runtime_error& error) { + require(std::string_view(error.what()).find("without links") != std::string_view::npos, + "hardlinked log rejected"); + } + 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; +} + +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"); + require(!fs::exists(recovery) && read_file(renderer) == "immutable renderer contents" && + read_file(renderer32) == "immutable 32-bit contents", "session restores and cleans journal"); + // 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 {} +} + +int main(int argc, char** argv) { + try { + if (argc == 2 && std::string_view{argv[1]} == "--fixture-game") { + Sleep(2000); + return 0; + } + if (argc == 2 && std::string_view{argv[1]} == "--fixture-launcher") return fixture_launcher(); + require(argc == 2 && std::string_view{argv[1]} == "--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..049ff3a --- /dev/null +++ b/tests/elevation_tests.cpp @@ -0,0 +1,410 @@ +// Exercise the actual private protocol and Win32 helpers without starting UAC. +#include "../src/elevation.cpp" + +#include +#include +#include +#include +#include +#include + +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)}; +} + +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() { + 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"); + require(elevation::rotate_log(log) && !fs::exists(log) && + fs::file_size(directory / L"steamfix.log.old.1") > 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"); +} + +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 { + check_protocol_and_transport(); + check_paths_and_file_operations(); + check_privilege_restoration(); + check_errors_and_entry_points(); + } 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/runtime_tests.cpp b/tests/runtime_tests.cpp new file mode 100644 index 0000000..703614a --- /dev/null +++ b/tests/runtime_tests.cpp @@ -0,0 +1,599 @@ +// 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()); + } +} + +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"); + expect_failure([&] { static_cast(access_allowed(INVALID_HANDLE_VALUE, token.get(), FILE_EXECUTE)); }, + "invalid access-check file was accepted"); + 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/win32_tests.cpp b/tests/win32_tests.cpp new file mode 100644 index 0000000..accf3ad --- /dev/null +++ b/tests/win32_tests.cpp @@ -0,0 +1,107 @@ +#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)}; + 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); + 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; + } + 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; +} From ba01b9b49fedf1823a7e897f335b42c2a41b0781 Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 19:13:55 +0000 Subject: [PATCH 02/12] Clarify lifetimes for native analysis and preserve optional renderer handling --- .github/workflows/build.yml | 3 ++- CMakeLists.txt | 1 - docs/CPP_CORE_GUIDELINES.md | 4 +++- make.cmd | 7 ++++--- src/acl.cpp | 33 ++++++++++++++++++--------------- src/app.cpp | 11 ++++++----- src/elevation.cpp | 7 ++++--- src/repair.cpp | 5 +++-- tests/app_tests.cpp | 14 ++++++++++---- tests/win32_tests.cpp | 9 +++++++++ 10 files changed, 59 insertions(+), 35 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 50cb078..122ddbb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -53,11 +53,12 @@ jobs: 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' + if: always() && matrix.configuration == 'Debug' && steps.coverage.outcome != 'skipped' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: steamfix-windows-x64-coverage diff --git a/CMakeLists.txt b/CMakeLists.txt index aba7d46..c6cb094 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,7 +26,6 @@ function(steamfix_enable_code_analysis target) VS_GLOBAL_CodeAnalysisRuleSet "${STEAMFIX_CODE_ANALYSIS_RULESET}" VS_GLOBAL_CodeAnalysisTreatWarningsAsErrors true ) - target_compile_options(${target} PRIVATE /analyze:WX) endif() endfunction() diff --git a/docs/CPP_CORE_GUIDELINES.md b/docs/CPP_CORE_GUIDELINES.md index 25e4709..6d139df 100644 --- a/docs/CPP_CORE_GUIDELINES.md +++ b/docs/CPP_CORE_GUIDELINES.md @@ -70,7 +70,9 @@ signed application boundary. **P.7, E.2, E.12, F.6.** Repair rejects failed or unexpected process waits before acting. The application has a final nonthrowing exception boundary covering startup, logging, and even failure-message formatting. Stage-specific errors -and the recovery journal remain available for diagnosis. +and the recovery journal remain available for diagnosis. A missing optional +32-bit renderer is distinguished from a filesystem inspection failure, including +standard-library implementations that also set an error code for a missing path. **SF.10, SF.11, Con.1.** Headers and source files include their direct dependencies. New local bindings are immutable where their values do not diff --git a/make.cmd b/make.cmd index 9544962..7549377 100644 --- a/make.cmd +++ b/make.cmd @@ -44,14 +44,15 @@ call :do_build Debug || goto failure goto success :test -call :do_configure || goto failure +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 -DSTEAMFIX_ENABLE_CODE_ANALYSIS=ON +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 @@ -91,7 +92,7 @@ if defined help_exit ( goto success :coverage -cmake -S . -B build-coverage -A x64 -DSTEAMFIX_ENABLE_COVERAGE=ON +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 diff --git a/src/acl.cpp b/src/acl.cpp index 3b9cda1..f2f4053 100644 --- a/src/acl.cpp +++ b/src/acl.cpp @@ -931,7 +931,6 @@ void remove_incomplete_state(const fs::path& path, const Logger& log) { } struct PreparedRecovery { - const RecoveryEntry* entry; OpenedTarget observed; OpenedTarget writable; }; @@ -939,19 +938,21 @@ 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; if (observed->identity != entry.identity) { log.warn("skipped ACL recovery because renderer identity changed: " + shown); return std::nullopt; @@ -966,7 +967,7 @@ std::optional prepare_recovery_entry(const RecoveryEntry& entr 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)}; + return PreparedRecovery{std::move(*observed), std::move(writable)}; } } // namespace @@ -1007,8 +1008,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"); diff --git a/src/app.cpp b/src/app.cpp index 650713e..c688fff 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -254,12 +255,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 { @@ -424,9 +425,9 @@ int entry() noexcept try { L"recovery files.\n\n" + widen(error.what()) + L"\n\nDestiny 2 was not started."); return static_cast(ExitCode::log_initialization); } - std::optional log; + std::unique_ptr log; try { - log.emplace(game_directory / L"steamfix.log"); + log = std::make_unique(game_directory / L"steamfix.log"); } catch (const elevation::Cancelled&) { return 0; } catch (const std::exception& error) { diff --git a/src/elevation.cpp b/src/elevation.cpp index 9ce0143..bf17a1b 100644 --- a/src/elevation.cpp +++ b/src/elevation.cpp @@ -86,9 +86,10 @@ fs::path executable_path(HANDLE process = nullptr) { template requires std::same_as || std::same_as void transfer(const HANDLE pipe, const std::span data, const DWORD timeout = pipe_timeout) { - std::span bytes = data; UniqueHandle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); - while (!bytes.empty()) { + 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{}; @@ -115,7 +116,7 @@ void transfer(const HANDLE pipe, const std::span data, const DWORD } if (completed == 0) fail(ERROR_BROKEN_PIPE); if (completed > requested) fail(ERROR_INVALID_DATA); - bytes = bytes.subspan(completed); + offset += completed; } } diff --git a/src/repair.cpp b/src/repair.cpp index 992e3ce..05e3d7c 100644 --- a/src/repair.cpp +++ b/src/repair.cpp @@ -431,8 +431,9 @@ void wait_for_repair(const HANDLE child, const fs::path& directory, const Logger 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/tests/app_tests.cpp b/tests/app_tests.cpp index 0875261..9a9213b 100644 --- a/tests/app_tests.cpp +++ b/tests/app_tests.cpp @@ -100,6 +100,11 @@ void check_errors() { 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); @@ -271,14 +276,15 @@ void info(std::wstring_view) noexcept {} void error(std::wstring_view) noexcept {} } -int main(int argc, char** argv) { +int main() { try { - if (argc == 2 && std::string_view{argv[1]} == "--fixture-game") { + const auto arguments = steamfix::app::command_line_arguments(); + if (arguments == std::vector{L"--fixture-game"}) { Sleep(2000); return 0; } - if (argc == 2 && std::string_view{argv[1]} == "--fixture-launcher") return fixture_launcher(); - require(argc == 2 && std::string_view{argv[1]} == "--entry-check", "test invocation"); + 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(); diff --git a/tests/win32_tests.cpp b/tests/win32_tests.cpp index accf3ad..31e46a3 100644 --- a/tests/win32_tests.cpp +++ b/tests/win32_tests.cpp @@ -33,6 +33,11 @@ bool check_handle_ownership() { 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; @@ -47,6 +52,10 @@ bool check_handle_ownership() { 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; From dadea7341224bb32cb87f71936121340d91efa14 Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 19:19:59 +0000 Subject: [PATCH 03/12] Complete lifetime analysis contracts and authenticated helper fixtures --- cmake/SummarizeCoverage.py | 30 ++- docs/CPP_CORE_GUIDELINES.md | 15 +- include/steamfix/acl.hpp | 5 +- include/steamfix/app.hpp | 5 +- include/steamfix/command_line.hpp | 5 +- include/steamfix/elevation.hpp | 5 +- include/steamfix/error.hpp | 5 +- include/steamfix/logging.hpp | 5 +- include/steamfix/overlay.hpp | 5 +- include/steamfix/paths.hpp | 5 +- include/steamfix/process.hpp | 5 +- include/steamfix/repair.hpp | 5 +- include/steamfix/steam.hpp | 5 +- include/steamfix/ui.hpp | 5 +- include/steamfix/win32.hpp | 5 +- src/acl.cpp | 67 ++++++- src/app.cpp | 68 ++++--- tests/app_tests.cpp | 55 +++++- tests/elevation_tests.cpp | 297 ++++++++++++++++++++++++++++++ 19 files changed, 536 insertions(+), 61 deletions(-) diff --git a/cmake/SummarizeCoverage.py b/cmake/SummarizeCoverage.py index da65440..e41b1f3 100644 --- a/cmake/SummarizeCoverage.py +++ b/cmake/SummarizeCoverage.py @@ -18,15 +18,29 @@ def line_ranges(lines): def summarize(report, source_root): files = {} - for item in ET.parse(report).iter("class"): + 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("\\", "/")) - if not source.is_absolute(): - source = source_root / source - try: - relative = source.resolve().relative_to(source_root).as_posix() - except ValueError: - continue - if not relative.startswith(("src/", "include/")): + 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"): diff --git a/docs/CPP_CORE_GUIDELINES.md b/docs/CPP_CORE_GUIDELINES.md index 6d139df..f45c892 100644 --- a/docs/CPP_CORE_GUIDELINES.md +++ b/docs/CPP_CORE_GUIDELINES.md @@ -74,8 +74,9 @@ and the recovery journal remain available for diagnosis. A missing optional 32-bit renderer is distinguished from a filesystem inspection failure, including standard-library implementations that also set an error code for a missing path. -**SF.10, SF.11, Con.1.** Headers and source files include their direct -dependencies. New local bindings are immutable where their values do not +**SF.8, SF.10, SF.11, Con.1.** All project headers use standard component-specific +include guards and compile independently, including when included twice. Headers +and source files include their direct dependencies. New local bindings are immutable where their values do not change. The project remains C++20 with warnings as errors and the existing security/linker settings. @@ -115,6 +116,16 @@ initialization, and lifetime diagnostics as errors; it does not check every guideline. The optional full Microsoft ruleset is described in [BUILDING.md](BUILDING.md). +Native MSVC analysis exposed limitations in its experimental lifetime model for +engaged optionals and owning aggregates containing filesystem paths. The +remaining ACL diagnostics have statement-specific suppressions with the storage +invariant documented next to each call; independent review confirmed the owners +outlive those uses. Tests also locally suppress moved-from diagnostics where +they intentionally verify an explicitly supported empty or preserved state. +No selected rule is disabled globally. Other reports were resolved by removing +a borrowed recovery-entry pointer, using immutable span views, copying a +timestamp value, and constructing the logger directly in its final scope. + Tests exercise actual production implementations and real Windows APIs on test-owned directories, registry overrides, pipes, mutexes, module fixtures, and child processes. They include malformed recovery inputs, DPAPI round trips, diff --git a/include/steamfix/acl.hpp b/include/steamfix/acl.hpp index 81f484f..b45263f 100644 --- a/include/steamfix/acl.hpp +++ b/include/steamfix/acl.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_ACL_HPP +#define STEAMFIX_ACL_HPP #include "steamfix/win32.hpp" @@ -52,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 56cc4c0..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() 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 d6f94c4..16ca370 100644 --- a/include/steamfix/elevation.hpp +++ b/include/steamfix/elevation.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_ELEVATION_HPP +#define STEAMFIX_ELEVATION_HPP #include "steamfix/win32.hpp" @@ -83,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 129052f..637e674 100644 --- a/include/steamfix/error.hpp +++ b/include/steamfix/error.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_ERROR_HPP +#define STEAMFIX_ERROR_HPP #include #include @@ -53,3 +54,5 @@ class AppError final : public std::runtime_error { }; } // 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 05c11f4..4ece956 100644 --- a/include/steamfix/win32.hpp +++ b/include/steamfix/win32.hpp @@ -1,4 +1,5 @@ -#pragma once +#ifndef STEAMFIX_WIN32_HPP +#define STEAMFIX_WIN32_HPP #include @@ -120,3 +121,5 @@ inline std::string windows_error(DWORD code = GetLastError()) { } } // namespace steamfix + +#endif // STEAMFIX_WIN32_HPP diff --git a/src/acl.cpp b/src/acl.cpp index f2f4053..992645a 100644 --- a/src/acl.cpp +++ b/src/acl.cpp @@ -953,6 +953,11 @@ std::optional prepare_recovery_entry(const RecoveryEntry& entr } }(); 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; @@ -963,10 +968,16 @@ 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())); +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif return PreparedRecovery{std::move(*observed), std::move(writable)}; } @@ -995,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; @@ -1045,16 +1061,22 @@ Protection Protection::apply(const std::span targets, fs::path s 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"); @@ -1066,13 +1088,18 @@ Protection Protection::apply(const std::span targets, fs::path s "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&) { @@ -1099,15 +1126,28 @@ Protection Protection::apply(const std::span targets, fs::path s 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) { +#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); } +#ifdef _MSC_VER +#pragma warning(suppress: 26486) +#endif backup.denied_acl = make_denied_acl(backup, identity.sid()); } } @@ -1127,14 +1167,25 @@ Protection Protection::apply(const std::span targets, fs::path s 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; diff --git a/src/app.cpp b/src/app.cpp index c688fff..628ae92 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -18,7 +18,6 @@ #include #include #include -#include #include #include #include @@ -392,6 +391,16 @@ 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() noexcept try { @@ -425,74 +434,79 @@ int entry() noexcept try { 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, inherited_steam_directory); - 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. diff --git a/tests/app_tests.cpp b/tests/app_tests.cpp index 9a9213b..b36b305 100644 --- a/tests/app_tests.cpp +++ b/tests/app_tests.cpp @@ -205,10 +205,12 @@ void check_logging(const fs::path& directory) { try { const steamfix::Logger unsafe(path); throw std::logic_error("expected linked log rejection"); - } catch (const std::runtime_error& error) { - require(std::string_view(error.what()).find("without links") != std::string_view::npos, - "hardlinked log rejected"); + } 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); } @@ -228,6 +230,35 @@ int fixture_launcher() { 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); +} + void check_session(const fs::path& directory) { using namespace steamfix; const Environment app_id(L"SteamAppId", L"1085660"); @@ -249,6 +280,21 @@ void check_session(const fs::path& directory) { 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"); // A valid path with invalid executable bytes forces CreateProcess failure @@ -279,6 +325,9 @@ void error(std::wstring_view) noexcept {} 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; diff --git a/tests/elevation_tests.cpp b/tests/elevation_tests.cpp index 049ff3a..6928df9 100644 --- a/tests/elevation_tests.cpp +++ b/tests/elevation_tests.cpp @@ -105,6 +105,139 @@ PipePair create_pipe_pair() { 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{}; @@ -346,6 +479,156 @@ void check_privilege_restoration() { 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_errors_and_entry_points() { require(elevation::permission_denied(ERROR_ACCESS_DENIED) && elevation::permission_denied(ERROR_PRIVILEGE_NOT_HELD) && @@ -398,10 +681,24 @@ void check_errors_and_entry_points() { 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)); + require(parsed != nullptr && count > 0, "Cannot parse the fixture command line"); + std::vector arguments; + for (const auto* argument : std::span{parsed.get(), static_cast(count)}.subspan(1)) { + arguments.emplace_back(argument); + } + if (const auto status = elevation::dispatch_helper(arguments)) return *status; check_protocol_and_transport(); check_paths_and_file_operations(); check_privilege_restoration(); check_errors_and_entry_points(); + check_authenticated_helper(); } catch (const std::exception& error) { std::cerr << "Elevation regression test failed: " << error.what() << '\n'; return 1; From 06b06f3a7769647aaf58d6f8427bda8b18493a0d Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 19:26:52 +0000 Subject: [PATCH 04/12] Fix native file contracts and Windows test activation contexts --- CMakeLists.txt | 6 ++-- cmake/RunCoverage.ps1 | 12 ++++++-- cmake/SummarizeCoverage.py | 9 ++++-- docs/CPP_CORE_GUIDELINES_PLAN.md | 7 +++-- src/elevation.cpp | 22 +++++++++------ src/repair.cpp | 3 ++ tests/elevation_tests.cpp | 48 +++++++++++++++++++++++++++++--- tests/runtime_tests.cpp | 17 +++++++++-- 8 files changed, 100 insertions(+), 24 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c6cb094..10dae76 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -83,7 +83,9 @@ endif() function(steamfix_add_test target) cmake_parse_arguments(TEST "" "" "ARGS" ${ARGN}) - add_executable(${target} ${TEST_UNPARSED_ARGUMENTS}) + # 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 @@ -117,7 +119,7 @@ if(BUILD_TESTING) # 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}) + 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 diff --git a/cmake/RunCoverage.ps1 b/cmake/RunCoverage.ps1 index 2715040..773cce1 100644 --- a/cmake/RunCoverage.ps1 +++ b/cmake/RunCoverage.ps1 @@ -46,9 +46,15 @@ if (Test-Path "LastCoverageResults.log") { } if (Test-Path (Join-Path $outputRoot "coverage.xml")) { - python (Join-Path $PSScriptRoot "SummarizeCoverage.py") ` - (Join-Path $outputRoot "coverage.xml") --source-root $sourceRoot ` - --output (Join-Path $outputRoot "summary.md") + $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." } diff --git a/cmake/SummarizeCoverage.py b/cmake/SummarizeCoverage.py index e41b1f3..a3975df 100644 --- a/cmake/SummarizeCoverage.py +++ b/cmake/SummarizeCoverage.py @@ -16,7 +16,7 @@ def line_ranges(lines): return ", ".join(str(start) if start == end else f"{start}-{end}" for start, end in ranges) -def summarize(report, source_root): +def summarize(report, source_root, test_run_failed=False): files = {} document = ET.parse(report) source_roots = [source_root] @@ -57,6 +57,8 @@ def summarize(report, source_root): 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", "", @@ -91,8 +93,11 @@ def main(): 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()) + 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"): diff --git a/docs/CPP_CORE_GUIDELINES_PLAN.md b/docs/CPP_CORE_GUIDELINES_PLAN.md index 7278c46..3885e9d 100644 --- a/docs/CPP_CORE_GUIDELINES_PLAN.md +++ b/docs/CPP_CORE_GUIDELINES_PLAN.md @@ -16,7 +16,8 @@ D2SteamFix temporarily changes Steam overlay permissions and restores them when - [x] (2026-10-16) Complete review and fixes across every C++ source/header and build configuration. - [x] (2026-10-16) Integrate six real-Windows fixture test suites, analyzer checks, and complete-source coverage reporting; all production and test executables cross-compile and link with MinGW warnings as errors. - [ ] Run supported MSVC Debug/Release builds, Windows regression suites, and production coverage in CI. -- [ ] Record exceptions and validation limits, commit version 1.1.1, and open a pull request. +- [x] (2026-10-16) Record exceptions and validation limits, commit version 1.1.1, and open [pull request 18](https://github.com/d2checkpoint-com/D2SteamFix/pull/18). +- [ ] Finish native CI and measured coverage, address testable gaps, complete final cross-review, and update the pull request with exact evidence. ## Surprises & Discoveries @@ -38,10 +39,12 @@ On 2026-10-16, split writers by file ownership in the shared checkout. ACL owner On 2026-10-16, the user requested independent cross-review after implementation and test coverage as close to 100% as possible. Expand tests to actual production implementation files and real Windows fixtures. Include every production translation unit in the coverage denominator using an unoptimized inventory executable, fail incomplete reports, and expose uncovered lines. Avoid inventing a percentage before Windows execution or excluding uncalled code to inflate coverage. +On 2026-10-16, native MSVC analysis reported valid owner lifetimes as invalid after optional/aggregate transfers. First simplify ownership and immutable view handling; where the experimental model still cannot follow owning filesystem paths, suppress only the reviewed statement and document its invariant. Keep every selected rule enabled. Independent cross-review covers these changes and the test-only moved-from contract checks. + ## Outcomes & Retrospective -Production review and corrective implementation are complete. Six suites now cover command-line quoting, resource/conversion helpers, application sessions, ACL/recovery, elevation/IPC, and runtime discovery/repair helpers. All binaries cross-compile and link with MinGW warnings as errors. Independent cross-review found and resolved exception-copy safety and an overly broad test deny mask. Supported Windows CI results and remaining coverage gaps will be recorded before delivery. +Production review and corrective implementation are complete. Six suites now cover command-line quoting, resource/conversion helpers, application sessions, ACL/recovery, elevation/IPC, and runtime discovery/repair helpers. All binaries cross-compile and link with MinGW warnings as errors; all thirteen public headers compile independently and on repeated inclusion. Independent cross-review found and resolved exception-copy safety, an overly broad test deny mask, and an incorrect test error expectation. Pull request 18 is open as a draft. Native CI has compiled the project and exposed analyzer-model limitations, now addressed with direct construction and documented statement-level exceptions. Supported Windows test outcomes and measured coverage remain pending. ## Context and Orientation diff --git a/src/elevation.cpp b/src/elevation.cpp index bf17a1b..2e7cf54 100644 --- a/src/elevation.cpp +++ b/src/elevation.cpp @@ -297,22 +297,26 @@ UniqueHandle checked_file(const fs::path& path, const DWORD access, const DWORD 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); constexpr std::size_t header_size = offsetof(FILE_RENAME_INFO, FileName); if (destination.size() > (MAXDWORD - header_size) / sizeof(wchar_t)) fail(ERROR_BAD_PATHNAME); const std::size_t name_bytes = destination.size() * sizeof(wchar_t); - FILE_RENAME_INFO rename{}; - rename.ReplaceIfExists = FALSE; - rename.RootDirectory = nullptr; - rename.FileNameLength = static_cast(name_bytes); - std::vector storage(std::max(sizeof(rename), header_size + name_bytes)); - // The Win32 variable-length structure is marshalled as bytes; no C++ - // FILE_RENAME_INFO object is overlaid on vector storage. - std::memcpy(storage.data(), &rename, header_size); + const DWORD length = static_cast(name_bytes); + std::vector storage(std::max(sizeof(FILE_RENAME_INFO), header_size + name_bytes)); + // 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()); + 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()); } diff --git a/src/repair.cpp b/src/repair.cpp index 05e3d7c..a387061 100644 --- a/src/repair.cpp +++ b/src/repair.cpp @@ -125,6 +125,9 @@ bool same_user(const HANDLE first, const HANDLE second) { } 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, diff --git a/tests/elevation_tests.cpp b/tests/elevation_tests.cpp index 6928df9..49cf41e 100644 --- a/tests/elevation_tests.cpp +++ b/tests/elevation_tests.cpp @@ -301,6 +301,21 @@ void check_protocol_and_transport() { } 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); + require(rename_bytes.size() == offsetof(FILE_RENAME_INFO, FileName) + archive_name.native().size() * 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)), + std::as_bytes(std::span{archive_name.native()})), + "The native rename filename bytes do not match their declared length"); TemporaryDirectory fixture; const fs::path& directory = fixture.path(); elevation::validate_path(directory / L"future.dat"); @@ -405,8 +420,32 @@ void check_paths_and_file_operations() { "Cannot resize the log fixture"); } create_file(directory / L"steamfix.log.old", "preserve"); - require(elevation::rotate_log(log) && !fs::exists(log) && - fs::file_size(directory / L"steamfix.log.old.1") > 4U * 1024U * 1024U, + 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()}; @@ -688,9 +727,10 @@ int main() { }; int count{}; const std::unique_ptr parsed(CommandLineToArgvW(GetCommandLineW(), &count)); - require(parsed != nullptr && count > 0, "Cannot parse the fixture command line"); + 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 : std::span{parsed.get(), static_cast(count)}.subspan(1)) { + for (const auto* argument : parsed_arguments.subspan(1)) { arguments.emplace_back(argument); } if (const auto status = elevation::dispatch_helper(arguments)) return *status; diff --git a/tests/runtime_tests.cpp b/tests/runtime_tests.cpp index 703614a..75dabaf 100644 --- a/tests/runtime_tests.cpp +++ b/tests/runtime_tests.cpp @@ -48,6 +48,17 @@ void check_status(const LSTATUS status) { } } +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() { @@ -407,8 +418,10 @@ void check_repair_files(const fs::path& root, const steamfix::Logger& log) { expect_failure([&] { verify_repaired(directory, true, log); }, "repair accepted an execute-denied renderer"); expect_failure([] { static_cast(token_user(nullptr)); }, "invalid token was accepted"); - expect_failure([&] { static_cast(access_allowed(INVALID_HANDLE_VALUE, token.get(), FILE_EXECUTE)); }, - "invalid access-check file 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, From b54726c8b19f8f23101cea983eb9157024ad4a6d Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 19:35:44 +0000 Subject: [PATCH 05/12] Terminate native rename paths and cover monitoring faults and real dialogs --- CMakeLists.txt | 6 + docs/BUILDING.md | 11 +- src/elevation.cpp | 7 +- tests/elevation_tests.cpp | 8 +- tests/process_fault_tests.cpp | 406 ++++++++++++++++++++++++++++++++++ tests/ui_entry_tests.cpp | 245 ++++++++++++++++++++ 6 files changed, 677 insertions(+), 6 deletions(-) create mode 100644 tests/process_fault_tests.cpp create mode 100644 tests/ui_entry_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 10dae76..6ca1828 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,6 +109,12 @@ if(BUILD_TESTING) 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(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) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 7ba675a..6b3ef0b 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -119,9 +119,16 @@ descriptors, and registry fixtures owned by the test. They cover: and temporary privilege restoration without requesting UAC. - 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. +- 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 -retained in the coverage denominator. The suite does not launch Steam, change -an installed game's permissions, request UAC, or perform a live Steam repair. +also exercised by the separate UI test when a Windows desktop is available. +That test reports a CTest skip (exit 77) if a desktop is unavailable before its +initial UI check succeeds; subsequent failures fail the test. The suite does not +launch Steam, 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/src/elevation.cpp b/src/elevation.cpp index 2e7cf54..13adc7c 100644 --- a/src/elevation.cpp +++ b/src/elevation.cpp @@ -301,10 +301,11 @@ std::vector rename_information(const fs::path& target) { std::wstring destination = target.native(); if (destination.starts_with(L"\\\\?\\")) destination.erase(0, 4); constexpr std::size_t header_size = offsetof(FILE_RENAME_INFO, FileName); - if (destination.size() > (MAXDWORD - header_size) / sizeof(wchar_t)) fail(ERROR_BAD_PATHNAME); + 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)); + 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. @@ -312,6 +313,8 @@ std::vector rename_information(const fs::path& target) { &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; } diff --git a/tests/elevation_tests.cpp b/tests/elevation_tests.cpp index 49cf41e..23dc291 100644 --- a/tests/elevation_tests.cpp +++ b/tests/elevation_tests.cpp @@ -303,7 +303,8 @@ void check_protocol_and_transport() { 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); - require(rename_bytes.size() == offsetof(FILE_RENAME_INFO, FileName) + archive_name.native().size() * sizeof(wchar_t), + 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{}; }), @@ -313,9 +314,12 @@ void check_paths_and_file_operations() { 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)), + 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"); 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/ui_entry_tests.cpp b/tests/ui_entry_tests.cpp new file mode 100644 index 0000000..4ee0482 --- /dev/null +++ b/tests/ui_entry_tests.cpp @@ -0,0 +1,245 @@ +#include "steamfix/command_line.hpp" +#include "steamfix/ui.hpp" +#include "steamfix/win32.hpp" + +#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; +}; + +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()); +} + +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 child_window_exists = false; +}; + +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; + search.child_window_exists = 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 bool desktop_verified) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(8); + bool child_window_exists = false; + 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; + child_window_exists = child_window_exists || search.child_window_exists; + if (!enumerated) { + if (!desktop_verified) throw DesktopUnavailable("Windows could not enumerate the current desktop"); + throw std::runtime_error("Windows could not enumerate the previously available 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"); + if (!desktop_verified && !child_window_exists) { + throw DesktopUnavailable("the current desktop could not display the information dialog"); + } + throw std::runtime_error("child exited without its expected D2SteamFix dialog"); + } + Sleep(50); + } + if (!desktop_verified && !child_window_exists) { + throw DesktopUnavailable("the current desktop could not expose the child information dialog"); + } + throw std::runtime_error("child did not display its expected D2SteamFix dialog within eight seconds"); +} + +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)); + found = text.found; + 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"); + if (!PostMessageW(dialog, WM_COMMAND, static_cast(IDOK), 0)) throw_windows_error(); + const DWORD wait = WaitForSingleObject(child.get(), 5000); + if (wait == WAIT_FAILED) throw_windows_error(); + require(wait == WAIT_OBJECT_0, "dialog child did not exit after its OK command"); + 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 bool desktop_verified) { + const ChildProcess child(executable, argument); + const HWND dialog = wait_for_dialog(child, expected_exit, desktop_verified); + inspect_and_close(dialog, child, expected_text, expected_exit); +} + +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"--show-info") { + steamfix::ui::info(information_text); + return 0; + } + if (argument == L"--show-error") { + steamfix::ui::error(error_text); + return 0; + } + const fs::path executable(argument); + require(fs::is_regular_file(executable), "built steamfix executable is missing"); + const fs::path self = current_executable(); + check_dialog(self, L"--show-info", information_text, 0, false); + check_dialog(self, L"--show-error", error_text, 0, true); + check_dialog(executable, L"D2SteamFix-test-invalid-forwarded.exe", + L"the forwarded executable must be destiny2launcher.exe", 12, true); + 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; +} From 8255e64f049129f2b546229413dc5de4f5cae2cc Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 19:42:54 +0000 Subject: [PATCH 06/12] Expand coordinator and confined repair workflow coverage --- CMakeLists.txt | 4 +- docs/BUILDING.md | 8 +- tests/app_tests.cpp | 174 +++++++++ tests/repair_workflow_tests.cpp | 670 ++++++++++++++++++++++++++++++++ tests/ui_entry_tests.cpp | 16 +- 5 files changed, 869 insertions(+), 3 deletions(-) create mode 100644 tests/repair_workflow_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ca1828..f8a9206 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,7 +102,7 @@ if(BUILD_TESTING) steamfix_add_test(win32_tests tests/win32_tests.cpp) set(STEAMFIX_APP_TEST_SOURCES ${STEAMFIX_SOURCES}) - list(REMOVE_ITEM STEAMFIX_APP_TEST_SOURCES src/app.cpp src/ui.cpp) + 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) @@ -111,6 +111,8 @@ if(BUILD_TESTING) 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) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 6b3ef0b..3c46b5a 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -122,6 +122,11 @@ descriptors, and registry fixtures owned by the test. They cover: - 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. @@ -129,6 +134,7 @@ 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. That test reports a CTest skip (exit 77) if a desktop is unavailable before its initial UI check succeeds; subsequent failures fail the test. The suite does not -launch Steam, change an installed game's permissions, request UAC, or perform a live Steam repair. +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/tests/app_tests.cpp b/tests/app_tests.cpp index b36b305..bc098b5 100644 --- a/tests/app_tests.cpp +++ b/tests/app_tests.cpp @@ -1,6 +1,122 @@ // 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 + +namespace app_fault { + +enum class Kind { process_error, cancelled, relaunch, standard_error, unexpected_error, real_launch }; + +struct Plan { + Kind kind = Kind::process_error; + DWORD error = ERROR_BAD_EXE_FORMAT; + bool elevated = false; + std::filesystem::path steam; + int cleanup_failures = 0; +}; + +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; + +class Scope final { +public: + explicit Scope(const Plan& plan) noexcept + : previous_(active), previous_restoring_(restoring), previous_failures_(cleanup_failures) { + active = &plan; + restoring = false; + cleanup_failures = plan.cleanup_failures; + } + ~Scope() noexcept { + active = previous_; + restoring = previous_restoring_; + cleanup_failures = previous_failures_; + } + 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_; +}; + +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 { + +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 #include "../src/app.cpp" +#undef CreateProcessW +#undef is_elevated +#undef launch_as_desktop_user #include @@ -297,6 +413,64 @@ void check_session(const fs::path& directory) { } require(!fs::exists(recovery) && read_file(renderer) == "immutable renderer contents" && read_file(renderer32) == "immutable 32-bit contents", "session restores and cleans journal"); + 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(); + } // 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"); diff --git a/tests/repair_workflow_tests.cpp b/tests/repair_workflow_tests.cpp new file mode 100644 index 0000000..7d27505 --- /dev/null +++ b/tests/repair_workflow_tests.cpp @@ -0,0 +1,670 @@ +// 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; +}; + +struct Plan { + TestDirectory temporary; + fs::path game = temporary.path() / L"game"; + fs::path steam = temporary.path() / L"Steam"; + fs::path recovery = game / L"steamfix.dat"; + std::unique_ptr logger; + 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(game); + 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 = std::make_unique(game / L"steamfix.log"); + logger->info("repair workflow fixture log must survive repair"); + } + + [[nodiscard]] const steamfix::Logger& log() const { + if (!logger) throw std::runtime_error("repair fixture logger is absent"); + 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::span arguments) { + if (arguments.size() != 4 || std::wstring_view(arguments[1]) != L"-forcesteamupdate" || + std::wstring_view(arguments[2]) != L"-forcepackagedownload" || std::wstring_view(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)}; + if (steamfix::paths::name_is(repair_workflow::executable_path(), L"steam.exe")) { + return repair_workflow::recreate_owned_renderers(parsed); + } + 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/ui_entry_tests.cpp b/tests/ui_entry_tests.cpp index 4ee0482..c42e896 100644 --- a/tests/ui_entry_tests.cpp +++ b/tests/ui_entry_tests.cpp @@ -188,9 +188,23 @@ void inspect_and_close(const HWND dialog, const ChildProcess& child, } 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"); - if (!PostMessageW(dialog, WM_COMMAND, static_cast(IDOK), 0)) throw_windows_error(); + const HWND button = GetDlgItem(dialog, IDOK); + require(button != nullptr && GetParent(button) == dialog, "owned dialog has no OK button"); + DWORD button_pid = 0; + GetWindowThreadProcessId(button, &button_pid); + std::array button_class{}; + require(button_pid == child.pid() && + GetClassNameW(button, button_class.data(), static_cast(button_class.size())) != 0 && + std::wstring_view(button_class.data()) == L"Button", "OK button does not belong to the test-owned dialog"); + require(is_owned_dialog(dialog, child.pid()), "dialog ownership changed before its OK button click"); + DWORD_PTR click_result = 0; + // The native button sends the full WM_COMMAND notification, including its + // control handle, which MessageBox uses to finish its modal dialog loop. + const LRESULT clicked = SendMessageTimeoutW(button, BM_CLICK, 0, 0, + SMTO_ABORTIFHUNG | SMTO_BLOCK, 1000, &click_result); const DWORD wait = WaitForSingleObject(child.get(), 5000); if (wait == WAIT_FAILED) throw_windows_error(); + require(clicked != 0 || wait == WAIT_OBJECT_0, "owned dialog OK button did not accept its click"); require(wait == WAIT_OBJECT_0, "dialog child did not exit after its OK command"); require(child.exit_status() == expected_exit, "dialog child returned an unexpected exit status"); } From 0bf0dbc8bbd10f1de367049f5d14dda0e905f51e Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 19:48:46 +0000 Subject: [PATCH 07/12] Exercise entry failure recovery and clarify fixture ownership --- cmake/RunCoverage.ps1 | 8 ++ cmake/RunCoverageTests.cmake | 2 + docs/BUILDING.md | 2 + docs/CPP_CORE_GUIDELINES.md | 25 ++++-- tests/app_tests.cpp | 155 +++++++++++++++++++++++++++++++- tests/repair_workflow_tests.cpp | 33 ++++--- tests/win32_tests.cpp | 16 ++++ 7 files changed, 221 insertions(+), 20 deletions(-) diff --git a/cmake/RunCoverage.ps1 b/cmake/RunCoverage.ps1 index 773cce1..bc88c6b 100644 --- a/cmake/RunCoverage.ps1 +++ b/cmake/RunCoverage.ps1 @@ -25,6 +25,10 @@ if (-not (Test-Path $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", @@ -44,6 +48,10 @@ $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 = @( diff --git a/cmake/RunCoverageTests.cmake b/cmake/RunCoverageTests.cmake index de0177f..9fbfcc4 100644 --- a/cmake/RunCoverageTests.cmake +++ b/cmake/RunCoverageTests.cmake @@ -1,3 +1,5 @@ +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}") diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 3c46b5a..0afe3de 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -83,6 +83,8 @@ 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. diff --git a/docs/CPP_CORE_GUIDELINES.md b/docs/CPP_CORE_GUIDELINES.md index f45c892..226f523 100644 --- a/docs/CPP_CORE_GUIDELINES.md +++ b/docs/CPP_CORE_GUIDELINES.md @@ -62,7 +62,8 @@ complete message remains serialized. **ES.46, ES.48, ES.50, SL.con.1, P.4.** Private IPC uses const-correct spans and explicit wire layout without padding disclosure. Rename lengths are checked -before conversion. Token headers are copied into typed locals, the privilege +before conversion; the native rename buffer has deterministic padding and an +explicit trailing terminator. Token headers are copied into typed locals, the privilege record has typed aligned storage, and application-owned fixed buffers use `std::array`. Windows exit-code bits use an explicit same-width `bit_cast` at the signed application boundary. @@ -73,6 +74,8 @@ startup, logging, and even failure-message formatting. Stage-specific errors and the recovery journal remain available for diagnosis. A missing optional 32-bit renderer is distinguished from a filesystem inspection failure, including standard-library implementations that also set an error code for a missing path. +Permission probes explicitly reject invalid handles, including the sentinel +whose bit pattern is also Windows' current-process pseudo-handle. **SF.8, SF.10, SF.11, Con.1.** All project headers use standard component-specific include guards and compile independently, including when included twice. Headers @@ -126,15 +129,27 @@ No selected rule is disabled globally. Other reports were resolved by removing a borrowed recovery-entry pointer, using immutable span views, copying a timestamp value, and constructing the logger directly in its final scope. -Tests exercise actual production implementations and real Windows APIs on +Nine test suites exercise actual production implementations and real Windows APIs on test-owned directories, registry overrides, pipes, mutexes, module fixtures, and child processes. They include malformed recovery inputs, DPAPI round trips, permission restoration and interrupted recovery, transport failures, process monitoring state, quoted/Unicode arguments, and launch failure cleanup. The application session test starts copies of the test program under the validated -launcher/game names inside its own temporary tree. It never launches real Steam -or Destiny 2. Dialogs in the application test are captured to keep CI -noninteractive; their production implementation still contributes to coverage. +launcher/game names inside its own temporary tree. Coordinator and repair tests +also supply finite failures at scoped external boundaries to check restoration, +cancellation, shutdown, verification, and recovery preservation. Repair discovery +and consent responses stay within the fixture; native file/token operations and +termination act on owned resources. No installed Steam client or Destiny 2 game +is launched. Application test dialogs use headless sinks; a separate test reads +and dismisses real production dialogs belonging to its own children and tests +the built wrapper's invalid-invocation entry point. An unavailable desktop is +reported as a skip, never as successful UI validation. + +Independent reviewers checked production changes outside their original file +ownership, then cross-reviewed the integrated test boundaries, native contracts, +cleanup lifetimes, and coverage inventory. Findings were resolved before the +final review; the resulting review reported no actionable regressions. This is +review evidence, rather than a proof of complete guideline conformance. Debug CI collects OpenCppCoverage HTML and Cobertura reports plus an uncovered line summary. An inventory executable retains every production translation unit diff --git a/tests/app_tests.cpp b/tests/app_tests.cpp index bc098b5..d25fdef 100644 --- a/tests/app_tests.cpp +++ b/tests/app_tests.cpp @@ -7,12 +7,18 @@ #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; @@ -20,6 +26,12 @@ struct Plan { 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; }; struct UnexpectedFailure final {}; @@ -29,19 +41,30 @@ struct UnexpectedFailure final {}; 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_(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; @@ -51,8 +74,42 @@ class Scope final { const Plan* previous_; bool previous_restoring_; int previous_failures_; + int previous_retry_calls_; + bool previous_retry_restored_; + int previous_messages_; }; +LPCWSTR command_line() { + 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) { @@ -78,6 +135,18 @@ BOOL WINAPI create_process(LPCWSTR application, LPWSTR command, LPSECURITY_ATTRI 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(); } @@ -113,11 +182,51 @@ bool app_test_desktop_launch(const std::filesystem::path& executable, const std: #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 || fs::path(arguments.front()) != app_fault::active->launcher || + arguments.back() != L"--fixture-launcher" || + directory != app_fault::active->launcher.parent_path() || 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 @@ -471,6 +580,46 @@ void check_session(const fs::path& directory) { // 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; + require(app::entry() == expected && app_fault::retry_calls == 1 && app_fault::retry_restored, + "entry administrator retry follows restoration and preserves its outcome"); + 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 < 3; ++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}; + const app_fault::Scope faults(plan); + const int expected = static_cast(failure == 0 ? ExitCode::invalid_invocation : + failure == 1 ? ExitCode::internal : ExitCode::existing_session); + 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"); @@ -493,7 +642,9 @@ void check_session(const fs::path& directory) { // 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 {} +void error(std::wstring_view) noexcept { + if (app_fault::active != nullptr) ++app_fault::error_messages; +} } int main() { diff --git a/tests/repair_workflow_tests.cpp b/tests/repair_workflow_tests.cpp index 7d27505..ab8fd9e 100644 --- a/tests/repair_workflow_tests.cpp +++ b/tests/repair_workflow_tests.cpp @@ -110,12 +110,18 @@ struct PickerAction { 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 = temporary.path() / L"game"; + fs::path game = make_game_directory(temporary.path()); fs::path steam = temporary.path() / L"Steam"; fs::path recovery = game / L"steamfix.dat"; - std::unique_ptr logger; + const steamfix::Logger logger{game / L"steamfix.log"}; std::map owned_processes; std::vector process_lifetimes; std::vector picker_actions; @@ -142,7 +148,6 @@ struct Plan { std::jthread watchdog; explicit Plan(const bool renderer32 = true, const std::string_view recreation = "both") { - fs::create_directory(game); fs::create_directory(steam); fs::copy_file(executable_path(), steam / L"steam.exe"); write_file(steam / L".d2steamfix-test-owner", @@ -153,14 +158,10 @@ struct Plan { for (const auto* name : {L"steamfix.dat", L"steamfix.dat.tmp", L"steamfix.dat.old"}) { write_file(game / name, "preserved recovery state"); } - logger = std::make_unique(game / L"steamfix.log"); - logger->info("repair workflow fixture log must survive repair"); + logger.info("repair workflow fixture log must survive repair"); } - [[nodiscard]] const steamfix::Logger& log() const { - if (!logger) throw std::runtime_error("repair fixture logger is absent"); - return *logger; - } + [[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".."; @@ -624,9 +625,9 @@ void check_picker_cancellation_and_failure() { } } -int recreate_owned_renderers(const std::span arguments) { - if (arguments.size() != 4 || std::wstring_view(arguments[1]) != L"-forcesteamupdate" || - std::wstring_view(arguments[2]) != L"-forcepackagedownload" || std::wstring_view(arguments[3]) != L"-exitsteam") return 90; +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; @@ -652,8 +653,14 @@ int main() { 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(parsed); + return repair_workflow::recreate_owned_renderers(owned_arguments); } repair_workflow::require(count == 1, "unexpected repair workflow test arguments"); repair_workflow::check_cancellation(); diff --git a/tests/win32_tests.cpp b/tests/win32_tests.cpp index 31e46a3..06f350e 100644 --- a/tests/win32_tests.cpp +++ b/tests/win32_tests.cpp @@ -89,6 +89,22 @@ bool check_text_conversion() { 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; } From a81b3d76a8bd03b12430264c5ed70a26441eb445 Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 19:57:18 +0000 Subject: [PATCH 08/12] Cover authenticated broker startup and administrator handoff safely --- docs/BUILDING.md | 4 + docs/CPP_CORE_GUIDELINES.md | 8 + docs/CPP_CORE_GUIDELINES_PLAN.md | 10 +- tests/app_tests.cpp | 23 ++- tests/elevation_tests.cpp | 333 ++++++++++++++++++++++++++++++- tests/ui_entry_tests.cpp | 28 +-- 6 files changed, 377 insertions(+), 29 deletions(-) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 0afe3de..2ce9075 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -119,6 +119,10 @@ descriptors, and registry fixtures owned by the test. They cover: 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 diff --git a/docs/CPP_CORE_GUIDELINES.md b/docs/CPP_CORE_GUIDELINES.md index 226f523..46c59f8 100644 --- a/docs/CPP_CORE_GUIDELINES.md +++ b/docs/CPP_CORE_GUIDELINES.md @@ -145,6 +145,14 @@ and dismisses real production dialogs belonging to its own children and tests the built wrapper's invalid-invocation entry point. An unavailable desktop is reported as a skip, never as successful UI validation. +Broker startup, reuse, transport-loss recovery, and administrator handoff tests +intercept the shell `runas` boundary and start only the same test executable with +ordinary `CreateProcessW`. These cases require an already elevated token. A +scoped parent-only elevation-report override reaches the relaunch path; helper +children use their actual token, process/pipe identity checks, and handle +duplication. This covers the coordinator protocol without claiming UAC consent +or alternate-account validation. + Independent reviewers checked production changes outside their original file ownership, then cross-reviewed the integrated test boundaries, native contracts, cleanup lifetimes, and coverage inventory. Findings were resolved before the diff --git a/docs/CPP_CORE_GUIDELINES_PLAN.md b/docs/CPP_CORE_GUIDELINES_PLAN.md index 3885e9d..7a0c039 100644 --- a/docs/CPP_CORE_GUIDELINES_PLAN.md +++ b/docs/CPP_CORE_GUIDELINES_PLAN.md @@ -14,7 +14,7 @@ D2SteamFix temporarily changes Steam overlay permissions and restores them when - [x] (2026-10-16) Read the C++ Core Guidelines skill and inventoried all tracked project files. - [x] (2026-10-16) Assigned independent owners for ACL recovery, elevation, runtime operations, and build checks; coordinator owns application and shared support code. - [x] (2026-10-16) Complete review and fixes across every C++ source/header and build configuration. -- [x] (2026-10-16) Integrate six real-Windows fixture test suites, analyzer checks, and complete-source coverage reporting; all production and test executables cross-compile and link with MinGW warnings as errors. +- [x] (2026-10-16) Integrate nine real-Windows fixture test suites, analyzer checks, and complete-source coverage reporting; all production and test executables cross-compile and link with MinGW warnings as errors. - [ ] Run supported MSVC Debug/Release builds, Windows regression suites, and production coverage in CI. - [x] (2026-10-16) Record exceptions and validation limits, commit version 1.1.1, and open [pull request 18](https://github.com/d2checkpoint-com/D2SteamFix/pull/18). - [ ] Finish native CI and measured coverage, address testable gaps, complete final cross-review, and update the pull request with exact evidence. @@ -24,10 +24,12 @@ D2SteamFix temporarily changes Steam overlay permissions and restores them when The skill main file is available, but its referenced rule index and guideline shards fail to load through the skill provider. The official isocpp CppCoreGuidelines document was downloaded to /tmp/CppCoreGuidelines.md for targeted rule lookup. This is a current upstream reference, rather than a verified copy of the skill's June 2026 snapshot. -The execution host is Linux and initially offers g++ but no Windows/MSVC SDK, MinGW, Wine, CMake, or clang-tidy. A MinGW GCC 14 toolchain and CMake were acquired under /tmp for supplementary compilation. All production sources, six test suites, an inventory executable, and an overlay fixture DLL compile and link. Supported MSVC builds and Windows execution still need CI; live administrator-prompt behavior needs manual Windows verification. +The execution host is Linux and initially offers g++ but no Windows/MSVC SDK, MinGW, Wine, CMake, or clang-tidy. A MinGW GCC 14 toolchain and CMake were acquired under /tmp for supplementary compilation. All production sources, test suites, an inventory executable, and an overlay fixture DLL compile and link. Supported MSVC builds, analysis, and Windows execution are performed through GitHub Actions; live administrator-prompt behavior needs manual Windows verification. Initial review identified copyable objects with pointers into their own storage, missing explicit special-member policies, unchecked binary extents, a temporary-journal cleanup ordering issue, and implicit DWORD-to-int exit-status conversion. +Native Windows tests additionally exposed an invalid-handle sentinel accepted as a process pseudo-handle, optional-file status behavior, Common Controls activation-context requirements in test binaries, and a missing native rename filename terminator. These were corrected without weakening warnings or omitting failed tests. Raw argument and nullable logger patterns in fixtures were simplified into direct ownership where Microsoft's experimental lifetime model could not follow their original ownership. + ## Decision Log @@ -44,7 +46,7 @@ On 2026-10-16, native MSVC analysis reported valid owner lifetimes as invalid af ## Outcomes & Retrospective -Production review and corrective implementation are complete. Six suites now cover command-line quoting, resource/conversion helpers, application sessions, ACL/recovery, elevation/IPC, and runtime discovery/repair helpers. All binaries cross-compile and link with MinGW warnings as errors; all thirteen public headers compile independently and on repeated inclusion. Independent cross-review found and resolved exception-copy safety, an overly broad test deny mask, and an incorrect test error expectation. Pull request 18 is open as a draft. Native CI has compiled the project and exposed analyzer-model limitations, now addressed with direct construction and documented statement-level exceptions. Supported Windows test outcomes and measured coverage remain pending. +Production review and corrective implementation are complete. Nine suites cover command-line quoting, resource/conversion helpers, application sessions and entry failures, ACL/recovery, elevation/IPC, runtime helpers, finite process-observation failures, confined repair workflows, and actual production dialogs/entry. All binaries cross-compile and link with MinGW warnings as errors; all thirteen public headers compile independently and on repeated inclusion. Independent cross-review found and resolved exception-copy safety, an overly broad test deny mask, incorrect error expectations, and canonical Windows path comparisons in a test. Separate final production reviewers reported no actionable regressions outside their original file ownership. Pull request 18 is open as a draft. Native production builds and selected analysis pass in Debug/Release; final expanded-suite outcomes and measured coverage remain pending. ## Context and Orientation @@ -67,7 +69,7 @@ From /workspace/D2SteamFix, inspect tracked files using git ls-files and rg. Use Portable command-line expectations pass with g++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -Iinclude tests/command_line_tests.cpp src/command_line.cpp -o /tmp/d2steamfix-command-line-tests followed by running that executable. -On a 64-bit Windows Visual Studio Developer command prompt, run make.cmd test, make.cmd analyze, and make.cmd coverage. Coverage requires OpenCppCoverage 0.9.9.0 and Python. CI builds Debug/Release with STEAMFIX_ENABLE_CODE_ANALYSIS=ON, runs all six suites, and collects Debug coverage. Reports are written under build/coverage in CI; make.cmd coverage writes build-coverage/coverage locally. See docs/BUILDING.md for complete direct CMake commands. +On a 64-bit Windows Visual Studio Developer command prompt, run make.cmd test, make.cmd analyze, and make.cmd coverage. Coverage requires OpenCppCoverage 0.9.9.0 and Python. CI builds Debug/Release with STEAMFIX_ENABLE_CODE_ANALYSIS=ON, runs all nine suites, and collects Debug coverage. Reports are written under build/coverage in CI; make.cmd coverage writes build-coverage/coverage locally. See docs/BUILDING.md for complete direct CMake commands. ## Validation and Acceptance diff --git a/tests/app_tests.cpp b/tests/app_tests.cpp index d25fdef..c1464cf 100644 --- a/tests/app_tests.cpp +++ b/tests/app_tests.cpp @@ -32,6 +32,7 @@ struct Plan { bool parse_failure = false; bool executable_failure = false; bool existing_session = false; + bool unexpected_startup_failure = false; }; struct UnexpectedFailure final {}; @@ -80,6 +81,7 @@ class Scope final { }; 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(); } @@ -207,9 +209,10 @@ std::optional app_test_relaunch(const std::vector& arguments, return relaunch_as_administrator(arguments, directory, steam_directory); } ++app_fault::retry_calls; - if (arguments.size() != 2 || fs::path(arguments.front()) != app_fault::active->launcher || + if (arguments.size() != 2 || !paths::equal_windows(fs::path(arguments.front()), app_fault::active->launcher) || arguments.back() != L"--fixture-launcher" || - directory != app_fault::active->launcher.parent_path() || steam_directory != app_fault::active->steam || + !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"); } @@ -590,8 +593,14 @@ void check_session(const fs::path& directory) { 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; - require(app::entry() == expected && app_fault::retry_calls == 1 && app_fault::retry_restored, - "entry administrator retry follows restoration and preserves its outcome"); + 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(); @@ -609,13 +618,13 @@ void check_session(const fs::path& directory) { "entry emits exactly one diagnostic for a noncancelled failure"); check_restored(); } - for (int failure = 0; failure < 3; ++failure) { + 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 == 0, failure == 1, failure == 2, failure == 3}; const app_fault::Scope faults(plan); const int expected = static_cast(failure == 0 ? ExitCode::invalid_invocation : - failure == 1 ? ExitCode::internal : ExitCode::existing_session); + 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(); diff --git a/tests/elevation_tests.cpp b/tests/elevation_tests.cpp index 23dc291..361f165 100644 --- a/tests/elevation_tests.cpp +++ b/tests/elevation_tests.cpp @@ -1,6 +1,13 @@ // Exercise the actual private protocol and Win32 helpers without starting UAC. -#include "../src/elevation.cpp" +#include "steamfix/elevation.hpp" + +#include +#include +#include +#include +#include +#include #include #include #include @@ -8,6 +15,211 @@ #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); +} + +// Only the shell consent boundary and the selected parent's elevation report +// are simulated. Pipes, authentication, processes, token reads in children, +// file access, handle duplication, and waits remain real production operations. +#define ShellExecuteExW ::elevation_fault::shell_execute +#define GetTokenInformation ::elevation_fault::token_information +#include "../src/elevation.cpp" +#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::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; +} + +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; @@ -672,6 +884,106 @@ void check_authenticated_helper() { } } +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_errors_and_entry_points() { require(elevation::permission_denied(ERROR_ACCESS_DENIED) && elevation::permission_denied(ERROR_PRIVILEGE_NOT_HELD) && @@ -738,11 +1050,30 @@ int main() { 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(); } catch (const std::exception& error) { std::cerr << "Elevation regression test failed: " << error.what() << '\n'; return 1; diff --git a/tests/ui_entry_tests.cpp b/tests/ui_entry_tests.cpp index c42e896..2f9f1f5 100644 --- a/tests/ui_entry_tests.cpp +++ b/tests/ui_entry_tests.cpp @@ -183,29 +183,23 @@ void inspect_and_close(const HWND dialog, const ChildProcess& child, 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)); - found = text.found; + // 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"); - const HWND button = GetDlgItem(dialog, IDOK); - require(button != nullptr && GetParent(button) == dialog, "owned dialog has no OK button"); - DWORD button_pid = 0; - GetWindowThreadProcessId(button, &button_pid); - std::array button_class{}; - require(button_pid == child.pid() && - GetClassNameW(button, button_class.data(), static_cast(button_class.size())) != 0 && - std::wstring_view(button_class.data()) == L"Button", "OK button does not belong to the test-owned dialog"); - require(is_owned_dialog(dialog, child.pid()), "dialog ownership changed before its OK button click"); - DWORD_PTR click_result = 0; - // The native button sends the full WM_COMMAND notification, including its - // control handle, which MessageBox uses to finish its modal dialog loop. - const LRESULT clicked = SendMessageTimeoutW(button, BM_CLICK, 0, 0, - SMTO_ABORTIFHUNG | SMTO_BLOCK, 1000, &click_result); + 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(clicked != 0 || wait == WAIT_OBJECT_0, "owned dialog OK button did not accept its click"); - require(wait == WAIT_OBJECT_0, "dialog child did not exit after its OK command"); + 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"); } From bf16998a71de3cdd88be3e47693d4f65e59da8a9 Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 20:08:17 +0000 Subject: [PATCH 09/12] Verify journal failure rollback and permission preflight boundaries --- CMakeLists.txt | 1 + tests/acl_tests.cpp | 209 +++++++++++++++++++++++++++++++++++++- tests/app_tests.cpp | 49 +++++++++ tests/elevation_tests.cpp | 139 ++++++++++++++++++++++++- 4 files changed, 391 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f8a9206..2ab79e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -122,6 +122,7 @@ if(BUILD_TESTING) 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 diff --git a/tests/acl_tests.cpp b/tests/acl_tests.cpp index b91d1ab..17c385c 100644 --- a/tests/acl_tests.cpp +++ b/tests/acl_tests.cpp @@ -1,12 +1,110 @@ -// Keep the production implementation in this translation unit so its private -// binary readers and recovery preflight run unchanged against real Win32 APIs. -#include "../src/acl.cpp" - +// 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; @@ -658,6 +756,107 @@ void state_file_and_neighbor_tests() { 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() { @@ -668,6 +867,8 @@ int main() { 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: " diff --git a/tests/app_tests.cpp b/tests/app_tests.cpp index c1464cf..80c34d6 100644 --- a/tests/app_tests.cpp +++ b/tests/app_tests.cpp @@ -238,6 +238,7 @@ std::optional app_test_relaunch(const std::vector& arguments, #include #include #include +#include #include #include #include @@ -487,6 +488,35 @@ int authenticated_entry(const fs::path& steam, const fs::path& launcher) { 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"); @@ -525,6 +555,25 @@ void check_session(const fs::path& directory) { } 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"); diff --git a/tests/elevation_tests.cpp b/tests/elevation_tests.cpp index 361f165..aabc551 100644 --- a/tests/elevation_tests.cpp +++ b/tests/elevation_tests.cpp @@ -19,14 +19,21 @@ 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); } -// Only the shell consent boundary and the selected parent's elevation report -// are simulated. Pipes, authentication, processes, token reads in children, -// file access, handle duplication, and waits remain real production operations. +// 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 @@ -97,6 +104,10 @@ struct Plan { 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; }; @@ -141,6 +152,32 @@ BOOL WINAPI token_information(const HANDLE token, const TOKEN_INFORMATION_CLASS 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 && @@ -984,6 +1021,101 @@ void check_broker_and_relaunch_boundaries() { 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) && @@ -1074,6 +1206,7 @@ int main() { 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; From f4d5726fa7f25f4eadef1c6199647b0b5f085d5b Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 20:13:46 +0000 Subject: [PATCH 10/12] Record final native validation, measured coverage, and cross-review --- docs/CPP_CORE_GUIDELINES.md | 28 ++++++++++++++++++++++------ docs/CPP_CORE_GUIDELINES_PLAN.md | 8 +++++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/CPP_CORE_GUIDELINES.md b/docs/CPP_CORE_GUIDELINES.md index 46c59f8..65b75a6 100644 --- a/docs/CPP_CORE_GUIDELINES.md +++ b/docs/CPP_CORE_GUIDELINES.md @@ -167,12 +167,28 @@ including production headers with emitted code; it is not branch coverage. Coverage is reported as measured, without excluding difficult code to inflate the percentage or replacing the user's goal with a lower arbitrary threshold. -Local verification so far: all production translation units and the initial -tests cross-compile as Windows x64 C++20 with MinGW GCC 14 and warnings as errors; -the application links against the Windows libraries. Portable command-line -expectations pass under GCC. This supplements, and does not replace, the -supported MSVC Debug/Release builds and Windows execution. Final CI and expanded -suite results are recorded in the pull request. +Native [Windows validation](https://github.com/d2checkpoint-com/D2SteamFix/actions/runs/38960070921) +of implementation commit `bf16998` passes MSVC Debug and Release builds with +`/W4 /WX`, all eleven selected Core Check rules, and all nine suites in each +configuration, with no conditional or CTest skips. Tests include real production +dialogs and the built wrapper's entry point. Independent production and +integrated test cross-reviews report no +open actionable findings. + +Measured full-source executable-line coverage is **89.47% (2,370/2,649)**. +ACL/recovery reaches 91.41%; process monitoring and repair each reach 93.47%. +The workflow's `steamfix-windows-x64-coverage` artifact contains annotated HTML, +Cobertura, every uncovered line range, and the complete test log. The remaining +lines stay in the denominator, including native API failure paths, size/resource +limits, desktop-token launches, and exceptional recovery/initialization paths. +The suite does not reach 100%; no lower percentage is treated as proof of +completeness. Journal write/flush and partial-denial failures specifically check +temporary-file cleanup, rollback, and preservation of real recovery state. + +Supplementary checks also pass: Windows x64 C++20 cross-compilation/linking with +MinGW GCC 14 and warnings as errors, portable command-line expectations under +GCC, and independent/repeated inclusion of all thirteen public headers. Native +MSVC execution above is the supported-platform evidence. Live Steam behavior, UAC consent/cancellation, alternate administrator accounts, and destructive repair against a real installation require the manual Windows diff --git a/docs/CPP_CORE_GUIDELINES_PLAN.md b/docs/CPP_CORE_GUIDELINES_PLAN.md index 7a0c039..40b928c 100644 --- a/docs/CPP_CORE_GUIDELINES_PLAN.md +++ b/docs/CPP_CORE_GUIDELINES_PLAN.md @@ -15,9 +15,9 @@ D2SteamFix temporarily changes Steam overlay permissions and restores them when - [x] (2026-10-16) Assigned independent owners for ACL recovery, elevation, runtime operations, and build checks; coordinator owns application and shared support code. - [x] (2026-10-16) Complete review and fixes across every C++ source/header and build configuration. - [x] (2026-10-16) Integrate nine real-Windows fixture test suites, analyzer checks, and complete-source coverage reporting; all production and test executables cross-compile and link with MinGW warnings as errors. -- [ ] Run supported MSVC Debug/Release builds, Windows regression suites, and production coverage in CI. +- [x] (2026-10-16) Run supported MSVC Debug/Release builds, Windows regression suites, and production coverage in CI. - [x] (2026-10-16) Record exceptions and validation limits, commit version 1.1.1, and open [pull request 18](https://github.com/d2checkpoint-com/D2SteamFix/pull/18). -- [ ] Finish native CI and measured coverage, address testable gaps, complete final cross-review, and update the pull request with exact evidence. +- [x] (2026-10-16) Finish native CI and measured coverage, address testable gaps, and complete final independent cross-review. Publish the final evidence in pull request 18. ## Surprises & Discoveries @@ -46,7 +46,9 @@ On 2026-10-16, native MSVC analysis reported valid owner lifetimes as invalid af ## Outcomes & Retrospective -Production review and corrective implementation are complete. Nine suites cover command-line quoting, resource/conversion helpers, application sessions and entry failures, ACL/recovery, elevation/IPC, runtime helpers, finite process-observation failures, confined repair workflows, and actual production dialogs/entry. All binaries cross-compile and link with MinGW warnings as errors; all thirteen public headers compile independently and on repeated inclusion. Independent cross-review found and resolved exception-copy safety, an overly broad test deny mask, incorrect error expectations, and canonical Windows path comparisons in a test. Separate final production reviewers reported no actionable regressions outside their original file ownership. Pull request 18 is open as a draft. Native production builds and selected analysis pass in Debug/Release; final expanded-suite outcomes and measured coverage remain pending. +Production review and corrective implementation are complete. Nine suites cover command-line quoting, resource/conversion helpers, application sessions and entry failures, ACL/recovery, elevation/IPC, runtime helpers, finite process-observation failures, confined repair workflows, and actual production dialogs/entry. [Native Windows run 38960070921](https://github.com/d2checkpoint-com/D2SteamFix/actions/runs/38960070921) validates implementation commit bf16998: MSVC Debug/Release with /W4 /WX, all eleven selected Core Check rules, and all nine suites pass in both configurations. Full-source line coverage is 89.47% (2,370/2,649), including uncalled production code. Annotated HTML, Cobertura, uncovered ranges, and the full test log are in its coverage artifact. This falls short of 100%; native error/resource limits and live desktop/UAC/alternate-account paths remain explicit gaps. + +Supplementary MinGW compilation/linking and portable quoting checks pass; all thirteen public headers compile independently and on repeated inclusion. Independent cross-review found and resolved exception-copy safety, an overly broad test deny mask, incorrect error expectations, and canonical Windows path comparisons in a test. Final production reviewers checked files outside their original ownership; integrated reviewers checked the completed tests and their resource/action boundaries. They report no open actionable findings. Version 1.1.1 is proposed in pull request 18; no merge, tag, release publication, or live Steam repair was performed. ## Context and Orientation From 5d76a7efb43c86883f0b938dd9b696871f049629 Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 20:32:07 +0000 Subject: [PATCH 11/12] Remove unneeded review documents --- docs/CPP_CORE_GUIDELINES.md | 197 ------------------------------- docs/CPP_CORE_GUIDELINES_PLAN.md | 98 --------------- 2 files changed, 295 deletions(-) delete mode 100644 docs/CPP_CORE_GUIDELINES.md delete mode 100644 docs/CPP_CORE_GUIDELINES_PLAN.md diff --git a/docs/CPP_CORE_GUIDELINES.md b/docs/CPP_CORE_GUIDELINES.md deleted file mode 100644 index 65b75a6..0000000 --- a/docs/CPP_CORE_GUIDELINES.md +++ /dev/null @@ -1,197 +0,0 @@ -# C++ Core Guidelines review - -This review covers the entire D2SteamFix project at version 1.1.1. The reference -is the supplied `cpp-core-guidelines` skill, with detailed rule lookup against -the official [C++ Core Guidelines](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines). -The skill's main instructions loaded, but its separate reference files were -unavailable from the provider. The fallback is current upstream, rather than a -verified copy of the skill's June 2026 snapshot. - -The review applies guideline reasons, notes, and exceptions to this Windows x64 -C++20 application. It is not a claim that every guideline is mechanically -enforced, that the strict type/bounds/lifetime profiles have been proved, or that -line coverage proves correctness. The Guidelines contain judgment calls and -unfinished rules; a compiler check alone cannot certify complete conformance. - -## Review coverage - -| Files | Areas checked | -| --- | --- | -| `src/acl.cpp`, `include/steamfix/acl.hpp` | Permission invariants, recovery compatibility, descriptor/ACE/SID extents, serialization, encryption, resource cleanup, restoration on failure | -| `src/elevation.cpp`, `include/steamfix/elevation.hpp` | Authenticated helper protocol, cancellation lifetime, const correctness, privileged operations, handle ownership, launch context, error propagation | -| `src/process.cpp`, `include/steamfix/process.hpp` | Process identity, observation reliability, fail-closed monitoring, wait results, signed arithmetic, exit-status representation | -| `src/repair.cpp`, `include/steamfix/repair.hpp` | Consent boundaries, target verification, token storage, wait failures, file lifetime, optional renderer behavior, cleanup ordering | -| `src/steam.cpp`, `src/paths.cpp`, their headers | Registry bounds, string validity, case-insensitive path comparisons, discovery errors, self-contained includes | -| `src/overlay.cpp`, `include/steamfix/overlay.hpp` | Snapshot lifetime, module enumeration errors, path and name validation | -| `src/app.cpp`, `src/main.cpp`, `include/steamfix/app.hpp` | Invocation validation, protected-session lifetime, error recovery, exception boundary, inherited discovery context | -| `src/command_line.cpp`, `include/steamfix/command_line.hpp` | Windows quoting rules, string ownership, direct includes | -| `src/logging.cpp`, `include/steamfix/logging.hpp` | File invariants, ownership, concurrent writes, lock duration, nonthrowing diagnostics | -| `src/ui.cpp`, `include/steamfix/ui.hpp` | Borrowed text, null termination at the API, nonthrowing fallback | -| `include/steamfix/win32.hpp`, `include/steamfix/error.hpp` | Resource wrappers, special members, Unicode sizes, exception copying, process status conversion | -| Build scripts, both workflows, resource/manifest files, repository guidance and documentation | C++20, warning/security settings, version generation, analysis, testing, coverage, consistency with runtime behavior | -| `tests/`, `cmake/` | Fixture isolation, resource/thread cleanup, test linkage, complete coverage denominator, failure visibility | - -## Corrected findings - -**C.21, C.37, C.41, C.49, R.1, R.12, E.6, E.16.** Resource and scope guards now -state their complete copy/move policy. Self-referencing security attributes -cannot be copied or moved. User identity owns independent SID bytes instead of -caching a pointer into a token buffer. Permission probes use resource wrappers. -The failed journal writer closes its exclusive temporary handle before deleting -the file. Protection destruction cannot propagate cleanup exceptions; explicit -restoration continues to report failures and preserves recovery state. - -**E.16, C.64, C.128.** Application and relaunch exceptions hold immutable shared -context so copying an exception does not allocate. Explicit nonthrowing moves -preserve both objects' accessor invariants. Tests check the type traits and -exception capture/rethrow behavior. - -**P.7, ES.65, ES.103, F.24, T.10, Bounds.1/Bounds.3.** ACL readers validate each -ACE's structural bounds and basic allow/deny ACE SID extents before native APIs -inspect the data. Other native ACE forms retain their Windows validation. Byte operations use -validated spans, and binary copying/IPC templates constrain their accepted -types. Permission protection accepts a bounded target view and rejects -duplicate targets before writing a recovery journal. Journal magic values, -layout, encryption, and recovery version remain unchanged. - -**I.2, CP.2, CP.20, R.1.** The authenticated Steam directory is returned to the -coordinator and passed explicitly. Administrator privileges are scoped and -restored; process-token changes are serialized to prevent overlapping -save/restore sequences. Log formatting runs outside the write lock, while each -complete message remains serialized. - -**ES.46, ES.48, ES.50, SL.con.1, P.4.** Private IPC uses const-correct spans and -explicit wire layout without padding disclosure. Rename lengths are checked -before conversion; the native rename buffer has deterministic padding and an -explicit trailing terminator. Token headers are copied into typed locals, the privilege -record has typed aligned storage, and application-owned fixed buffers use -`std::array`. Windows exit-code bits use an explicit same-width `bit_cast` at the -signed application boundary. - -**P.7, E.2, E.12, F.6.** Repair rejects failed or unexpected process waits before -acting. The application has a final nonthrowing exception boundary covering -startup, logging, and even failure-message formatting. Stage-specific errors -and the recovery journal remain available for diagnosis. A missing optional -32-bit renderer is distinguished from a filesystem inspection failure, including -standard-library implementations that also set an error code for a missing path. -Permission probes explicitly reject invalid handles, including the sentinel -whose bit pattern is also Windows' current-process pseudo-handle. - -**SF.8, SF.10, SF.11, Con.1.** All project headers use standard component-specific -include guards and compile independently, including when included twice. Headers -and source files include their direct dependencies. New local bindings are immutable where their values do not -change. The project remains C++20 with warnings as errors and the existing -security/linker settings. - -## Deliberate Windows API boundaries - -I.30 requires containing necessary unsafe operations. Win32 interoperability -still uses `HANDLE`, `HKEY`, `DWORD`, native structures, null-terminated strings, -and borrowed ACL/SID pointers. Their lifetime is held by a resource wrapper or -the surrounding owned storage. Native unsigned fields describe bit masks, -Windows API extents, or the existing serialized format. Signed values are used -for arithmetic counters that are not API extents. - -Some Windows permission and encryption APIs accept mutable pointers for input -they do not modify. One documented adaptation in `acl.cpp` isolates this -constness mismatch, following the ES.50 exception for interfaces without -const-correct input types. Runtime byte representation at these C API -boundaries remains local; strict Type/Bounds profiles are not asserted. - -The helper sends a duplicated handle into its authenticated parent. If response -delivery fails after duplication, the parent can hold an unreported handle -until it exits. Closing that remote value on timeout can race with successful -adoption and invalidate an in-use or recycled handle. Transport failure drops -the connection and propagates an error; Windows reclaims the handle at parent -exit. This retained failure-path limitation is explicitly not claimed as full -RAII coverage of distributed handle transfer. - -The permission broker and privilege mutex have application lifetime, initialized -on first use. They are encapsulated inside the elevation implementation; no -mutable discovery state is exposed. Removing this remaining service lifetime -would require a separate explicit coordinator-context refactor. - -## Verification and coverage - -`make.cmd test` runs the automated suite. `make.cmd analyze` adds the checked-in -Microsoft C++ Core Check ruleset. It enforces selected ownership, special-member, -initialization, and lifetime diagnostics as errors; it does not check every -guideline. The optional full Microsoft ruleset is described in -[BUILDING.md](BUILDING.md). - -Native MSVC analysis exposed limitations in its experimental lifetime model for -engaged optionals and owning aggregates containing filesystem paths. The -remaining ACL diagnostics have statement-specific suppressions with the storage -invariant documented next to each call; independent review confirmed the owners -outlive those uses. Tests also locally suppress moved-from diagnostics where -they intentionally verify an explicitly supported empty or preserved state. -No selected rule is disabled globally. Other reports were resolved by removing -a borrowed recovery-entry pointer, using immutable span views, copying a -timestamp value, and constructing the logger directly in its final scope. - -Nine test suites exercise actual production implementations and real Windows APIs on -test-owned directories, registry overrides, pipes, mutexes, module fixtures, and -child processes. They include malformed recovery inputs, DPAPI round trips, -permission restoration and interrupted recovery, transport failures, process -monitoring state, quoted/Unicode arguments, and launch failure cleanup. The -application session test starts copies of the test program under the validated -launcher/game names inside its own temporary tree. Coordinator and repair tests -also supply finite failures at scoped external boundaries to check restoration, -cancellation, shutdown, verification, and recovery preservation. Repair discovery -and consent responses stay within the fixture; native file/token operations and -termination act on owned resources. No installed Steam client or Destiny 2 game -is launched. Application test dialogs use headless sinks; a separate test reads -and dismisses real production dialogs belonging to its own children and tests -the built wrapper's invalid-invocation entry point. An unavailable desktop is -reported as a skip, never as successful UI validation. - -Broker startup, reuse, transport-loss recovery, and administrator handoff tests -intercept the shell `runas` boundary and start only the same test executable with -ordinary `CreateProcessW`. These cases require an already elevated token. A -scoped parent-only elevation-report override reaches the relaunch path; helper -children use their actual token, process/pipe identity checks, and handle -duplication. This covers the coordinator protocol without claiming UAC consent -or alternate-account validation. - -Independent reviewers checked production changes outside their original file -ownership, then cross-reviewed the integrated test boundaries, native contracts, -cleanup lifetimes, and coverage inventory. Findings were resolved before the -final review; the resulting review reported no actionable regressions. This is -review evidence, rather than a proof of complete guideline conformance. - -Debug CI collects OpenCppCoverage HTML and Cobertura reports plus an uncovered -line summary. An inventory executable retains every production translation unit -so code uncalled by tests still contributes to the denominator. Missing source -files make coverage reporting fail. This measures executable-line coverage, -including production headers with emitted code; it is not branch coverage. -Coverage is reported as measured, without excluding difficult code to inflate -the percentage or replacing the user's goal with a lower arbitrary threshold. - -Native [Windows validation](https://github.com/d2checkpoint-com/D2SteamFix/actions/runs/38960070921) -of implementation commit `bf16998` passes MSVC Debug and Release builds with -`/W4 /WX`, all eleven selected Core Check rules, and all nine suites in each -configuration, with no conditional or CTest skips. Tests include real production -dialogs and the built wrapper's entry point. Independent production and -integrated test cross-reviews report no -open actionable findings. - -Measured full-source executable-line coverage is **89.47% (2,370/2,649)**. -ACL/recovery reaches 91.41%; process monitoring and repair each reach 93.47%. -The workflow's `steamfix-windows-x64-coverage` artifact contains annotated HTML, -Cobertura, every uncovered line range, and the complete test log. The remaining -lines stay in the denominator, including native API failure paths, size/resource -limits, desktop-token launches, and exceptional recovery/initialization paths. -The suite does not reach 100%; no lower percentage is treated as proof of -completeness. Journal write/flush and partial-denial failures specifically check -temporary-file cleanup, rollback, and preservation of real recovery state. - -Supplementary checks also pass: Windows x64 C++20 cross-compilation/linking with -MinGW GCC 14 and warnings as errors, portable command-line expectations under -GCC, and independent/repeated inclusion of all thirteen public headers. Native -MSVC execution above is the supported-platform evidence. - -Live Steam behavior, UAC consent/cancellation, alternate administrator accounts, -and destructive repair against a real installation require the manual Windows -checks in [AGENTS.md](../AGENTS.md). They are not covered by compiling or by -isolated fixtures. Catastrophic OS/API failures and allocation failures also -leave executable error paths that may remain uncovered. diff --git a/docs/CPP_CORE_GUIDELINES_PLAN.md b/docs/CPP_CORE_GUIDELINES_PLAN.md deleted file mode 100644 index 40b928c..0000000 --- a/docs/CPP_CORE_GUIDELINES_PLAN.md +++ /dev/null @@ -1,98 +0,0 @@ -# Review and strengthen C++ Core Guidelines alignment - - -This ExecPlan is a living document. Maintain Progress, Surprises & Discoveries, Decision Log, and Outcomes & Retrospective as the work proceeds. Follow [AGENTS.md](../AGENTS.md), fetched from newer upstream documentation during the review. No repository PLANS.md was present. - -## Purpose / Big Picture - - -D2SteamFix temporarily changes Steam overlay permissions and restores them when Destiny 2 exits. This review preserves that behavior while making ownership, byte-buffer validation, exception boundaries, and build checks follow the applicable C++ Core Guidelines. The outcome is a version 1.1.1 pull request with review coverage, explicit Windows API exceptions, regression checks, and honest validation limits. A review cannot establish the absence of every possible defect or prove the full lifetime safety profile. - -## Progress - - -- [x] (2026-10-16) Read the C++ Core Guidelines skill and inventoried all tracked project files. -- [x] (2026-10-16) Assigned independent owners for ACL recovery, elevation, runtime operations, and build checks; coordinator owns application and shared support code. -- [x] (2026-10-16) Complete review and fixes across every C++ source/header and build configuration. -- [x] (2026-10-16) Integrate nine real-Windows fixture test suites, analyzer checks, and complete-source coverage reporting; all production and test executables cross-compile and link with MinGW warnings as errors. -- [x] (2026-10-16) Run supported MSVC Debug/Release builds, Windows regression suites, and production coverage in CI. -- [x] (2026-10-16) Record exceptions and validation limits, commit version 1.1.1, and open [pull request 18](https://github.com/d2checkpoint-com/D2SteamFix/pull/18). -- [x] (2026-10-16) Finish native CI and measured coverage, address testable gaps, and complete final independent cross-review. Publish the final evidence in pull request 18. - -## Surprises & Discoveries - - -The skill main file is available, but its referenced rule index and guideline shards fail to load through the skill provider. The official isocpp CppCoreGuidelines document was downloaded to /tmp/CppCoreGuidelines.md for targeted rule lookup. This is a current upstream reference, rather than a verified copy of the skill's June 2026 snapshot. - -The execution host is Linux and initially offers g++ but no Windows/MSVC SDK, MinGW, Wine, CMake, or clang-tidy. A MinGW GCC 14 toolchain and CMake were acquired under /tmp for supplementary compilation. All production sources, test suites, an inventory executable, and an overlay fixture DLL compile and link. Supported MSVC builds, analysis, and Windows execution are performed through GitHub Actions; live administrator-prompt behavior needs manual Windows verification. - -Initial review identified copyable objects with pointers into their own storage, missing explicit special-member policies, unchecked binary extents, a temporary-journal cleanup ordering issue, and implicit DWORD-to-int exit-status conversion. - -Native Windows tests additionally exposed an invalid-handle sentinel accepted as a process pseudo-handle, optional-file status behavior, Common Controls activation-context requirements in test binaries, and a missing native rename filename terminator. These were corrected without weakening warnings or omitting failed tests. Raw argument and nullable logger patterns in fixtures were simplified into direct ownership where Microsoft's experimental lifetime model could not follow their original ownership. - -## Decision Log - - -On 2026-10-16, choose a patch release (1.1.1), because the changes correct implementation and validation without adding a user feature. The user explicitly requested a version bump and pull request if changes are made. - -On 2026-10-16, preserve the existing Windows application, permission journal format, and launch/repair behavior. Encapsulate necessary C ABI operations under I.30 instead of introducing an unrelated platform or dependency migration. Windows handles are owned only by dedicated resource wrappers. - -On 2026-10-16, split writers by file ownership in the shared checkout. ACL owner edits acl.cpp/acl.hpp; elevation owner edits elevation.cpp/elevation.hpp; runtime owner edits process, repair, steam, and overlay; build owner edits CMake, workflows, make.cmd, and new tests. Coordinator integrates shared win32 support, entry handling, command-line/path/log/UI code, and documentation. - -On 2026-10-16, the user requested independent cross-review after implementation and test coverage as close to 100% as possible. Expand tests to actual production implementation files and real Windows fixtures. Include every production translation unit in the coverage denominator using an unoptimized inventory executable, fail incomplete reports, and expose uncovered lines. Avoid inventing a percentage before Windows execution or excluding uncalled code to inflate coverage. - -On 2026-10-16, native MSVC analysis reported valid owner lifetimes as invalid after optional/aggregate transfers. First simplify ownership and immutable view handling; where the experimental model still cannot follow owning filesystem paths, suppress only the reviewed statement and document its invariant. Keep every selected rule enabled. Independent cross-review covers these changes and the test-only moved-from contract checks. - -## Outcomes & Retrospective - - -Production review and corrective implementation are complete. Nine suites cover command-line quoting, resource/conversion helpers, application sessions and entry failures, ACL/recovery, elevation/IPC, runtime helpers, finite process-observation failures, confined repair workflows, and actual production dialogs/entry. [Native Windows run 38960070921](https://github.com/d2checkpoint-com/D2SteamFix/actions/runs/38960070921) validates implementation commit bf16998: MSVC Debug/Release with /W4 /WX, all eleven selected Core Check rules, and all nine suites pass in both configurations. Full-source line coverage is 89.47% (2,370/2,649), including uncalled production code. Annotated HTML, Cobertura, uncovered ranges, and the full test log are in its coverage artifact. This falls short of 100%; native error/resource limits and live desktop/UAC/alternate-account paths remain explicit gaps. - -Supplementary MinGW compilation/linking and portable quoting checks pass; all thirteen public headers compile independently and on repeated inclusion. Independent cross-review found and resolved exception-copy safety, an overly broad test deny mask, incorrect error expectations, and canonical Windows path comparisons in a test. Final production reviewers checked files outside their original ownership; integrated reviewers checked the completed tests and their resource/action boundaries. They report no open actionable findings. Version 1.1.1 is proposed in pull request 18; no merge, tag, release publication, or live Steam repair was performed. - -## Context and Orientation - - -src/app.cpp validates the Steam invocation and holds the permission protection session while the game runs. src/acl.cpp reads and writes Windows access control lists (ACLs: file permission records) and the recovery journal. src/elevation.cpp requests administrator operations through a helper and named pipe (a Windows process communication channel). src/process.cpp watches verified game processes; src/repair.cpp performs the explicit Steam repair flow. include/steamfix/win32.hpp contains wrappers that release Windows resources automatically. CMakeLists.txt selects C++20 and MSVC; .github/workflows/build.yml builds Windows configurations and release.yml publishes tagged releases. No release tag is created by this task. - -## Plan of Work and Milestones - - -First, read every tracked source/header and supporting build/documentation file. Each owner checks applicable rules, including their reasons and exceptions, and reports findings with rule IDs. Acceptance for this milestone is complete file coverage and concrete findings rather than pattern matching alone. - -Second, fix ownership and lifetime hazards, use bounded views for buffer operations, constrain serialization to appropriate types, preserve Windows exit-code bits explicitly, move inherited launch context out of global state, and ensure exceptions cannot escape the Windows entry point. Keep recovery compatibility and security checks intact. Add focused tests for stable behaviors, including command-line quoting. Acceptance is integrated changes with documented API boundaries and no accidental behavior or journal format changes. - -Third, enable practical compiler analysis in the Windows build, run portable checks locally, and obtain additional Windows compiler evidence if available. Run the Windows CTest suite in Debug and Release in CI. Record which checks ran versus which are configured or pending. Review the final diff, bump version 1.1.1, commit on a dedicated branch, push, and open a pull request targeting the repository default branch. - -## Concrete Steps - - -From /workspace/D2SteamFix, inspect tracked files using git ls-files and rg. Use rg against /tmp/CppCoreGuidelines.md and read only relevant rule sections. Review source changes with git diff --check and git diff. - -Portable command-line expectations pass with g++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -Iinclude tests/command_line_tests.cpp src/command_line.cpp -o /tmp/d2steamfix-command-line-tests followed by running that executable. - -On a 64-bit Windows Visual Studio Developer command prompt, run make.cmd test, make.cmd analyze, and make.cmd coverage. Coverage requires OpenCppCoverage 0.9.9.0 and Python. CI builds Debug/Release with STEAMFIX_ENABLE_CODE_ANALYSIS=ON, runs all nine suites, and collects Debug coverage. Reports are written under build/coverage in CI; make.cmd coverage writes build-coverage/coverage locally. See docs/BUILDING.md for complete direct CMake commands. - -## Validation and Acceptance - - -Quoting tests must cover empty arguments, whitespace, embedded quotes, backslash runs before quotes, trailing backslashes, and multiple appended arguments. Where available, Windows CommandLineToArgvW must recover the original arguments. Resource and conversion tests should validate ownership transfer and cleanup behavior without modifying real Steam/game files. - -Debug and Release Windows builds must preserve /W4 /WX and existing hardening. Analyzer findings must be addressed or narrowly justified at the API boundary. A manual Windows launch/repair test must be reported as pending if no Windows runtime is available; compiling or parsing is not evidence of successful UAC or ACL recovery behavior. - -## Idempotence and Recovery - - -All local changes remain in a dedicated branch for review. Tests use temporary data and do not operate on Steam or game files. Preserve existing recovery files and user work. Do not merge, tag, publish a release, or run real repair operations. Repeat read-only checks safely; reconcile remote branch and pull-request state before retrying an uncertain write. - -## Artifacts and Notes - - -The review record will be docs/CPP_CORE_GUIDELINES.md. It will describe file coverage, corrected findings with rule IDs, deliberate C API exceptions, checks, and remaining limitations. The version derives from CMakeLists.txt, including generated Windows version resources and log version text. - -## Interfaces and Dependencies - - -Use C++20 standard facilities, including std::span, std::array, constrained templates, and std::bit_cast where they express existing requirements. Keep existing Windows libraries and APIs. steamfix::windows_exit_status(DWORD) will preserve Windows process status bits as the existing signed application return type. elevation::accept_elevated_launch will return an optional Steam directory, which app.cpp passes explicitly to its launch flow. - -Revision note (2026-10-16): Updated after production fixes, expanded testing, supplementary cross-compilation, and independent cross-review; supported Windows execution and coverage are the remaining validation milestone. From 6e3ef45941bb5730b3659c101e42fc75edbb2556 Mon Sep 17 00:00:00 2001 From: camo Date: Fri, 16 Oct 2026 20:42:01 +0000 Subject: [PATCH 12/12] Fail missing UI dialogs after an independent desktop probe --- docs/BUILDING.md | 10 ++-- tests/ui_entry_tests.cpp | 125 +++++++++++++++++++++++++++++++-------- 2 files changed, 106 insertions(+), 29 deletions(-) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 2ce9075..f96b7ff 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -138,9 +138,11 @@ descriptors, and registry fixtures owned by the test. They cover: 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. -That test reports a CTest skip (exit 77) if a desktop is unavailable before its -initial UI check succeeds; subsequent failures fail the test. The suite does not -launch an installed Steam client, change an installed game's permissions, -request UAC, or perform a live Steam repair. +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/tests/ui_entry_tests.cpp b/tests/ui_entry_tests.cpp index 2f9f1f5..1405340 100644 --- a/tests/ui_entry_tests.cpp +++ b/tests/ui_entry_tests.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include namespace { @@ -29,6 +30,20 @@ class DesktopUnavailable final : public std::runtime_error { 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); } @@ -37,6 +52,53 @@ void require(const bool condition, const char* message) { 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())); @@ -104,7 +166,6 @@ bool is_owned_dialog(const HWND window, const DWORD pid) noexcept { struct DialogSearch { DWORD pid; HWND found = nullptr; - bool child_window_exists = false; }; BOOL CALLBACK find_dialog(const HWND window, const LPARAM context) noexcept { @@ -113,39 +174,28 @@ BOOL CALLBACK find_dialog(const HWND window, const LPARAM context) noexcept { DWORD owner = 0; GetWindowThreadProcessId(window, &owner); if (owner != search.pid) return TRUE; - search.child_window_exists = 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 bool desktop_verified) { - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(8); - bool child_window_exists = 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; - child_window_exists = child_window_exists || search.child_window_exists; - if (!enumerated) { - if (!desktop_verified) throw DesktopUnavailable("Windows could not enumerate the current desktop"); - throw std::runtime_error("Windows could not enumerate the previously available desktop"); - } + 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"); - if (!desktop_verified && !child_window_exists) { - throw DesktopUnavailable("the current desktop could not display the information dialog"); - } - throw std::runtime_error("child exited without its expected D2SteamFix dialog"); + throw DialogExpectationFailure(DialogFailure::exited_without_dialog); } Sleep(50); } - if (!desktop_verified && !child_window_exists) { - throw DesktopUnavailable("the current desktop could not expose the child information dialog"); - } - throw std::runtime_error("child did not display its expected D2SteamFix dialog within eight seconds"); + throw DialogExpectationFailure(DialogFailure::deadline); } struct TextSearch { @@ -204,13 +254,28 @@ void inspect_and_close(const HWND dialog, const ChildProcess& child, } void check_dialog(const fs::path& executable, const std::wstring_view argument, - const std::wstring_view expected_text, const DWORD expected_exit, - const bool desktop_verified) { + const std::wstring_view expected_text, const DWORD expected_exit) { const ChildProcess child(executable, argument); - const HWND dialog = wait_for_dialog(child, expected_exit, desktop_verified); + 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); } }; @@ -226,6 +291,11 @@ int main() { } 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; @@ -234,13 +304,18 @@ int main() { 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"); - const fs::path self = current_executable(); - check_dialog(self, L"--show-info", information_text, 0, false); - check_dialog(self, L"--show-error", error_text, 0, true); + 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, true); + 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';