diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..025bf3b --- /dev/null +++ b/.clang-format @@ -0,0 +1,18 @@ +BasedOnStyle: Microsoft +Standard: c++20 +ColumnLimit: 120 +IndentWidth: 4 +NamespaceIndentation: All +TabWidth: 4 +UseTab: Never +BreakBeforeBraces: Custom +BraceWrapping: + AfterClass: true + AfterControlStatement: Never + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterStruct: true + BeforeCatch: false + BeforeElse: false +SortIncludes: CaseSensitive diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..92a0aa8 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,13 @@ +--- +Checks: > + -*, + clang-analyzer-*, + bugprone-*, + performance-*, + portability-*, + -bugprone-easily-swappable-parameters +WarningsAsErrors: '*' +HeaderFilterRegex: '.*[\\/]src[\\/].*' +SystemHeaders: false +FormatStyle: file +... diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fa796e8..973fa73 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,97 +2,236 @@ name: CI on: push: - branches: [ "master" ] + branches: [master] pull_request: - branches: [ "master" ] + branches: [master] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: - contents: write + contents: read env: - VCPKG_DEFAULT_BINARY_CACHE: "C:/vcpkg-binary-cache" + VCPKG_ROOT: C:\vcpkg + VCPKG_DEFAULT_BINARY_CACHE: C:\vcpkg-binary-cache jobs: - build: + verification: + name: ${{ matrix.job }} runs-on: windows-2022 + timeout-minutes: 120 strategy: + fail-fast: false matrix: - arch: [ x64, x86 ] + include: + - job: source + arch: none + - job: x86 + arch: x86 + - job: x64 + arch: x64 + - job: arm64-cross + arch: arm64 + steps: - - uses: actions/checkout@v4 + - name: Check out the repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - - name: Setup Developer Command Prompt - uses: ilammy/msvc-dev-cmd@v1 + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - arch: ${{ matrix.arch }} + version: "0.12.1" + enable-cache: true + cache-dependency-glob: build/uv.lock + cache-suffix: ${{ matrix.job }} - - name: Get project vcpkg baseline + - name: Identify the hosted runner image + id: runner-image shell: pwsh run: | - $baseline = (Get-Content -Path vcpkg.json | ConvertFrom-Json).'builtin-baseline' - echo "VCPKG_BASELINE=$baseline" >> $env:GITHUB_ENV + if ([string]::IsNullOrWhiteSpace($env:ImageOS) -or + [string]::IsNullOrWhiteSpace($env:ImageVersion)) { + throw 'GitHub runner image identity is unavailable.' + } + "identity=$($env:ImageOS)-$($env:ImageVersion)" | Add-Content -Path $env:GITHUB_OUTPUT + + - name: Cache vcpkg binaries + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-${{ steps.runner-image.outputs.identity }}-${{ matrix.arch }}-${{ hashFiles('vcpkg.json', 'build/vcpkg/triplets/*.cmake') }} - - name: Cache vcpkg - uses: actions/cache@v4 + - name: Restore completed build nodes + id: restore-build-cas + if: matrix.job != 'source' + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: - key: vcpkg-${{ matrix.arch }}-${{ hashFiles('vcpkg.json') }} - path: | - ${{env.VCPKG_DEFAULT_BINARY_CACHE}} + path: out/cas + key: cas-v1-${{ matrix.job }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + cas-v1-${{ matrix.job }}-${{ github.sha }}- + cas-v1-${{ matrix.job }}- - - name: Setup vcpkg + - name: Prepare the pinned Python environment + shell: pwsh run: | - New-Item -ItemType Directory -Path C:/my-vcpkg - Set-Location -Path C:/my-vcpkg - git init - git remote add --no-tags origin https://github.com/microsoft/vcpkg.git - git fetch --depth 1 --no-write-fetch-head origin ${{env.VCPKG_BASELINE}} - git branch master ${{env.VCPKG_BASELINE}} - git checkout - ./bootstrap-vcpkg.bat - New-Item -ItemType Directory -Path ${{env.VCPKG_DEFAULT_BINARY_CACHE}} -Force - echo "VCPKG_ROOT=C:/my-vcpkg" >> $env:GITHUB_ENV - - - name: Configure CMake - run: cmake --preset ${{ matrix.arch }}-release - - - name: Build - run: cmake --build ${{github.workspace}}/build/${{ matrix.arch }}-release - - - name: Pack + "TEMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV + "TMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV + New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null + $baseline = (Get-Content -Raw -LiteralPath 'vcpkg.json' | ConvertFrom-Json).'builtin-baseline' + if ($baseline -notmatch '^[0-9a-f]{40}$') { + throw "Invalid vcpkg builtin baseline: $baseline" + } + git -C $env:VCPKG_ROOT cat-file -e "$baseline^{commit}" 2>$null + if ($LASTEXITCODE -ne 0) { + git -C $env:VCPKG_ROOT fetch --no-tags --depth=1 origin $baseline + if ($LASTEXITCODE -ne 0) { + throw "Unable to fetch vcpkg baseline $baseline." + } + } + git -C $env:VCPKG_ROOT -c advice.detachedHead=false ` + checkout --detach --force $baseline + if ($LASTEXITCODE -ne 0) { + throw "Unable to check out vcpkg baseline $baseline." + } + & (Join-Path $env:VCPKG_ROOT 'bootstrap-vcpkg.bat') -disableMetrics + if ($LASTEXITCODE -ne 0) { + throw "Unable to bootstrap vcpkg baseline $baseline." + } + uv sync --project build --frozen + + - name: Provision external verification tools + shell: pwsh run: | - cd ${{github.workspace}}/build/${{ matrix.arch }}-release - cpack --config CPackConfig.cmake -C RelWithDebInfo + choco install cppcheck --version=2.19.0 --yes --no-progress + $cppcheck = 'C:\Program Files\Cppcheck\cppcheck.exe' + if (-not (Test-Path -LiteralPath $cppcheck -PathType Leaf)) { + throw "Cppcheck was not found after installation: $cppcheck" + } + (Split-Path -Parent $cppcheck) | Add-Content -Path $env:GITHUB_PATH + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Repository PSGallery -Scope CurrentUser -Force + $binskimRoot = Join-Path $env:RUNNER_TEMP 'binskim' + nuget install Microsoft.CodeAnalysis.BinSkim -Version 4.4.9.11 ` + -OutputDirectory $binskimRoot -DirectDownload -NonInteractive + $binskim = Join-Path $binskimRoot ` + 'Microsoft.CodeAnalysis.BinSkim.4.4.9.11\tools\net9.0\win-x64\BinSkim.exe' + if (-not (Test-Path -LiteralPath $binskim -PathType Leaf)) { + throw "BinSkim was not found after installation: $binskim" + } + (Split-Path -Parent $binskim) | Add-Content -Path $env:GITHUB_PATH + + $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) + $umdh = Join-Path $programFilesX86 'Windows Kits\10\Debuggers\x64\umdh.exe' + if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) { + $installer = Join-Path $env:RUNNER_TEMP 'winsdksetup.exe' + Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/?linkid=2349110' -OutFile $installer + $signature = Get-AuthenticodeSignature -LiteralPath $installer + if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Microsoft') { + throw "Windows SDK installer signature validation failed: $($signature.Status)" + } + $process = Start-Process -FilePath $installer -ArgumentList @( + '/features', 'OptionId.WindowsDesktopDebuggers', + '/quiet', '/norestart', '/ceip', 'off' + ) -Wait -PassThru + if ($process.ExitCode -notin @(0, 3010)) { + throw "Windows Debugging Tools installation failed with exit code $($process.ExitCode)." + } + } + if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) { + throw "UMDH was not found after Windows Debugging Tools setup: $umdh" + } + + - name: Provision the Windows 10 UMDH workaround + if: matrix.job == 'x64' + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP 'winsdk-19041' + $extract = Join-Path $root 'extracted' + $msi = Join-Path $root 'debuggers-x64.msi' + New-Item -ItemType Directory -Force -Path $root | Out-Null + Invoke-WebRequest -Uri 'https://download.microsoft.com/download/e119c04b-71aa-4067-ac3c-360c2e13d209/windowssdk/Installers/X64%20Debuggers%20And%20Tools-x64_en-us.msi' -OutFile $msi + $expected = '354173D844D5C061050EE2638AA94FAFB4835AC3DE836E220F6A74A992849A3B' + if ((Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash -ne $expected) { + throw 'Windows 10 Debugging Tools payload hash validation failed.' + } + $signature = Get-AuthenticodeSignature -LiteralPath $msi + if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Microsoft') { + throw "Windows 10 Debugging Tools payload signature validation failed: $($signature.Status)" + } + $arguments = @('/a', $msi, '/qn', '/norestart', "TARGETDIR=$extract") + $process = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" ` + -ArgumentList $arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Windows 10 Debugging Tools extraction failed with exit code $($process.ExitCode)." + } + $debuggers = Join-Path $extract 'Windows Kits\10\Debuggers\x64' + $umdh = Join-Path $debuggers 'umdh.exe' + $gflags = Join-Path $debuggers 'gflags.exe' + if (-not (Test-Path -LiteralPath $umdh -PathType Leaf)) { + throw "Windows 10 UMDH was not found after setup: $umdh" + } + if (-not (Test-Path -LiteralPath $gflags -PathType Leaf)) { + throw "Windows 10 GFlags was not found after setup: $gflags" + } + $version = [Diagnostics.FileVersionInfo]::GetVersionInfo($umdh).FileVersion + if (-not $version.StartsWith('10.0.19041.', [StringComparison]::Ordinal)) { + throw "Unexpected Windows 10 UMDH version: $version" + } + "OBSERVER_UMDH=$umdh" | Add-Content -Path $env:GITHUB_ENV + + - name: Check prerequisites through the public entry point + shell: pwsh + run: ./build.ps1 doctor - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: observer-modules-${{ matrix.arch }} - path: ${{github.workspace}}/build/${{ matrix.arch }}-release/*.zip + - name: Verify repository sources + id: verify-source + if: matrix.job == 'source' + shell: pwsh + run: | + $evidence = Join-Path $env:RUNNER_TEMP 'evidence-source' + ./build.ps1 verify-source -ExportDir $evidence - release: - needs: build - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/master' - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 + - name: Verify one architecture + id: verify-arch + if: matrix.job != 'source' + shell: pwsh + run: | + $evidence = Join-Path $env:RUNNER_TEMP 'evidence-${{ matrix.job }}' + $fuzzSeconds = if ('${{ github.event_name }}' -eq 'pull_request') { 5 } else { 60 } + $leakWarmup = if ('${{ github.event_name }}' -eq 'pull_request') { 1 } else { 8 } + $leakIterations = if ('${{ github.event_name }}' -eq 'pull_request') { 1 } else { 100 } + ./build.ps1 verify-arch -Arch '${{ matrix.arch }}' -ExportDir $evidence ` + -TestShards 4 -FuzzSeconds $fuzzSeconds -LeakWarmup $leakWarmup ` + -LeakIterations $leakIterations -LeakWindows 3 -PruneCas + + - name: Save completed build nodes + if: always() && matrix.job != 'source' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: - merge-multiple: true - path: ./artifacts - - - name: Generate release tag - id: tag - run: echo "tag=$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT + path: out/cas + key: ${{ steps.restore-build-cas.outputs.cache-primary-key }} - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + - name: Upload verification evidence + if: always() && (steps.verify-source.outcome != 'skipped' || steps.verify-arch.outcome != 'skipped') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: evidence-${{ matrix.job }} + path: | + ${{ runner.temp }}/evidence-${{ matrix.job }}/manifest.json + ${{ runner.temp }}/evidence-${{ matrix.job }}/reports + ${{ runner.temp }}/evidence-${{ matrix.job }}/logs + if-no-files-found: error + retention-days: ${{ github.event_name == 'pull_request' && 7 || 30 }} + + - name: Upload master packages and symbols + if: success() && github.event_name == 'push' && matrix.job != 'source' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - tag_name: release-${{ steps.tag.outputs.tag }} - name: 'Release ${{ steps.tag.outputs.tag }}' - body: | - Automated release from master branch. - - Download the *-dll.zip files if you need Observer modules. - Download the *-pdb.zip files if you need debug symbols. - files: ./artifacts/*.zip - prerelease: false + name: packages-${{ matrix.job }} + path: ${{ runner.temp }}/evidence-${{ matrix.job }}/packages + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index 0530e07..7312e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ -/.PVS-Studio -/build +/out/ +/build/.venv/ +/build/.uv-cache/ +/build/.coverage* +/.idea/ diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a1..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml deleted file mode 100644 index 4ea72a9..0000000 --- a/.idea/copilot.data.migration.agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml deleted file mode 100644 index 7fbe760..0000000 --- a/.idea/dictionaries/project.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - andelf - auriemma - birkenfeld - bstatic - catchorg - debugfarhome - dependencygraph - dest - funcs - ilammy - lazyhamster - luxrck - makemoduleversion - mateidavid - nomoreitems - popd - pushd - refaim's - ren' - renpy - rgssad - rpatool - rpgmaker - shizmob - softprops - strbuf - thirdparty - userabort - vcvars - wstring - xxhash - zanzapak - zanzarah - zstr - - - \ No newline at end of file diff --git a/.idea/editor.xml b/.idea/editor.xml deleted file mode 100644 index 2c855b4..0000000 --- a/.idea/editor.xml +++ /dev/null @@ -1,343 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 0b76fe5..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 348b976..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Copy_x64_debug_to_Far.xml b/.idea/runConfigurations/Copy_x64_debug_to_Far.xml deleted file mode 100644 index e0a01cc..0000000 --- a/.idea/runConfigurations/Copy_x64_debug_to_Far.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Copy_x64_release_to_Far.xml b/.idea/runConfigurations/Copy_x64_release_to_Far.xml deleted file mode 100644 index 0c2d86e..0000000 --- a/.idea/runConfigurations/Copy_x64_release_to_Far.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Copy_x86_debug_to_Far.xml b/.idea/runConfigurations/Copy_x86_debug_to_Far.xml deleted file mode 100644 index 2096b9b..0000000 --- a/.idea/runConfigurations/Copy_x86_debug_to_Far.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Copy_x86_release_to_Far.xml b/.idea/runConfigurations/Copy_x86_release_to_Far.xml deleted file mode 100644 index 808b0e3..0000000 --- a/.idea/runConfigurations/Copy_x86_release_to_Far.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Far_x64_debug.xml b/.idea/runConfigurations/Far_x64_debug.xml deleted file mode 100644 index bed2e21..0000000 --- a/.idea/runConfigurations/Far_x64_debug.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Far_x64_release.xml b/.idea/runConfigurations/Far_x64_release.xml deleted file mode 100644 index 3fe2c78..0000000 --- a/.idea/runConfigurations/Far_x64_release.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Far_x86_debug.xml b/.idea/runConfigurations/Far_x86_debug.xml deleted file mode 100644 index f1492bb..0000000 --- a/.idea/runConfigurations/Far_x86_debug.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Far_x86_release.xml b/.idea/runConfigurations/Far_x86_release.xml deleted file mode 100644 index 3dd962a..0000000 --- a/.idea/runConfigurations/Far_x86_release.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Run_Tests.xml b/.idea/runConfigurations/Run_Tests.xml deleted file mode 100644 index b5dd467..0000000 --- a/.idea/runConfigurations/Run_Tests.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d8aae9e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# Repository agent instructions + +## Build + +This project has a console-first Windows build based directly on MSBuild and pinned manifest-mode vcpkg dependencies. +Repository CMake is not part of the build graph; CMake use inside a vcpkg port is acceptable. + +Use the root entry point from an ordinary PowerShell or `cmd.exe` console: + +```powershell +.\build.ps1 doctor +.\build.ps1 build -Arch all -Config Release +.\build.ps1 test -Arch x86,x64 -Config Debug +.\build.ps1 verify-source -ExportDir +.\build.ps1 verify-arch -Arch x64 -ExportDir +.\build.ps1 verify -Arch x64 +``` + +Run the relevant build, tests, and checks after changing code. Do not install or update developer tools automatically; +report missing prerequisites to the user. Release modules must remain MSVC-built, `/MT`, self-contained binaries for +x86, x64, and ARM64 with no third-party runtime DLLs. + +All production changes follow strict TDD: state the observable requirement or invariant, add a focused test that fails +for the expected reason, implement the smallest correct change, and refactor only while the suite remains green. Every +bug fix starts with a regression test. The required coverage gate is 100% first-party source lines and branches; do not +weaken thresholds, exclude production files, write coverage-only tests with no behavioral assertion, or add broad +suppressions to make a check pass. + +## Development guidelines + +This project uses C++23 and follows the high-assurance engineering policy in +`docs/critical-software-methodology.md`. In particular: + +- Preserve clean dependency direction: platform/Observer adapters depend on application and parser code, never the + reverse. Format parsing belongs in a platform-neutral core and must not acquire Observer or Win32 dependencies. +- Keep the C ABI boundary strict. Export only C-compatible, fixed-layout data and explicit sizes. Never let STL types, + C++ exceptions, allocator ownership, or implicit lifetime assumptions cross the boundary. Validate all inbound + pointers and structure sizes, initialize outputs defensively, and translate every internal failure to the documented + ABI result at the outermost boundary. +- Use value semantics and RAII for every resource, including memory, files, module handles, and temporary output. + Application C++ must not contain owning `new`, `delete`, `malloc`, `calloc`, `realloc`, or `free`. A raw pointer or + reference is non-owning; prefer references, `std::span`, and `std::string_view` where they express the contract. + Prefer `std::unique_ptr` for polymorphic ownership. `std::shared_ptr` requires a documented, genuinely shared + lifetime; use `std::weak_ptr` to break cycles. +- Treat archive bytes, metadata, paths, counts, offsets, sizes, and callback behavior as untrusted input. Validate + before use, use checked arithmetic before narrowing/allocation/seeking, impose explicit resource and iteration + bounds, guarantee loop progress, and avoid input-driven recursion unless a strict depth limit is enforced. +- Express security-relevant condition combinations as executable, data-driven decision-table tests. +- Keep functions cohesive, control flow reviewable, ownership explicit, and preprocessor use minimal. Avoid magic + numbers, hidden global state, duplicated policy, and speculative abstraction. KISS and DRY remain subordinate to + clear boundaries and independently testable behavior. +- A check suppression or deviation must be narrow, explained beside the code or in the decision log, and reviewed by + the owner. Never catch `std::bad_alloc`, `std::length_error`, access violations, or sanitizer findings merely to make + fuzzing or tests pass. + +## Architecture overview + +This project implements Observer plugin modules for FAR Manager that handle exotic archive formats. Each module +implements the Observer API for one format family. + +### Core components + +- **API layer** (`src/api.h`, `src/dll.cpp`): Observer entry points such as `OpenStorage`, `CloseStorage`, `GetItem`, + and `ExtractItem`. +- **Archive wrapper** (`src/archive.h`, `src/archive.cpp`): common archive lifecycle and extraction behavior. +- **Extractor interface** (`src/modules/extractor.h`): the internal contract implemented by each format module. + +### Module structure + +Supported modules live under `src/modules/`: + +- `renpy/`: Ren'Py RPA archives and their Pickle index parser; +- `rpgmaker/`: RPG Maker VX Ace RGSS3A archives; +- `zanzarah/`: Zanzarah PAK archives. + +Each contains format-specific implementation, a `.def` export definition, and `observer_user.ini` registration data. + +### Data flow + +1. FAR Manager/Observer loads the module through `LoadSubModule()`. +2. `OpenStorage()` creates an archive wrapper with the format extractor. +3. `PrepareFiles()` validates and indexes archive contents. +4. `GetItem()` exposes entry metadata. +5. `ExtractItem()` streams an entry to the requested destination with progress/cancellation reporting. + +### Tests + +Catch2 tests live in `src/tests/`. Unit tests exercise parser logic directly, while integration and ABI contract tests +load the actual module binaries without requiring FAR Manager. Small repository-owned fixtures are mandatory. The +external golden corpus is an optional compatibility/stress layer selected with `-Corpus`. + +See `docs/build-system.md` for the current command contract and build architecture. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b4e1c08..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,56 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build - -This project uses CMake with vcpkg for dependency management, builds only on Windows and requires Visual Studio 2017+. - -Do not attempt to build the code or run tests, as the environment is not set up for it. - -## Development Guidelines - -This project uses **C++23** and follows KISS (Keep It Simple, Stupid) and DRY (Don't Repeat Yourself) principles. - -Avoid magic numbers. - -## Architecture Overview - -This project implements Observer plugin modules for FAR Manager that handle exotic archive formats. The codebase follows -a plugin architecture where each module implements the Observer API to support different archive formats. - -### Core Components - -- **API Layer** (`src/api.h`, `src/dll.cpp`): Implements the Observer plugin API with standard functions like - `OpenStorage`, `CloseStorage`, `GetItem`, `ExtractItem` -- **Archive Wrapper** (`src/archive.h`, `src/archive.cpp`): Provides a unified interface that wraps format-specific - extractors -- **Extractor Interface** (`src/modules/extractor.h`): Defines the abstract interface that all format extractors must - implement - -### Module Structure - -Each supported format has its own module under `src/modules/`: - -- `renpy/`: RenPy visual novel archives (.rpa files) with pickle support -- `zanzarah/`: Zanzarah game archives (.pak files) -- `rpgmaker/`: RPG Maker archives (in development) - -Each module contains: - -- Format-specific implementation (e.g., `renpy.cpp`) -- Module definition file (`.def`) for DLL exports -- Configuration file (`observer_user.ini`) - -### Data Flow - -1. FAR Manager loads the module DLL via `LoadSubModule()` -2. `OpenStorage()` creates an archive wrapper with format-specific extractor -3. `PrepareFiles()` scans and indexes archive contents -4. `GetItem()` provides file metadata for FAR's file browser -5. `ExtractItem()` handles actual file extraction with progress callbacks - -### Testing Framework - -Located in `src/tests/` with a custom framework (`framework/observer.h`) that simulates the Observer API for testing -archive operations without requiring FAR Manager. \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 6508d49..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,96 +0,0 @@ -cmake_minimum_required(VERSION 3.31) - -if (DEFINED ENV{VCPKG_ROOT}) - set(VCPKG_ROOT "$ENV{VCPKG_ROOT}") -elseif (DEFINED ENV{USERPROFILE}) - set(VCPKG_ROOT "$ENV{USERPROFILE}/.vcpkg-clion/vcpkg") -endif () -if (NOT EXISTS ${VCPKG_ROOT}) - message(FATAL_ERROR "VCPKG_ROOT is not defined. Please set it to the path of your vcpkg installation.") -endif () -file(TO_CMAKE_PATH ${VCPKG_ROOT} VCPKG_ROOT) -set(CMAKE_TOOLCHAIN_FILE "${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake") - -set(VCPKG_CRT_LINKAGE static) -set(VCPKG_LIBRARY_LINKAGE static) -set(VCPKG_TARGET_TRIPLET ${OBSERVER_ARCHITECTURE}-windows-static) - -project(observer_modules LANGUAGES CXX) -set(CMAKE_CXX_STANDARD 23) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -add_compile_options("/W3" "/analyze") -set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") - -set(ZLIB_USE_STATIC_LIBS ON) -find_package(ZLIB REQUIRED) -find_path(ZSTR_INCLUDE_DIRS "zstr.hpp") - -set(ALL_MODULES renpy rpgmaker zanzarah) -set(RELEASED_MODULES renpy rpgmaker zanzarah) - -set(RENPY_DIR src/modules/renpy) -add_library(renpy SHARED - src/dll.cpp - src/archive.cpp - ${RENPY_DIR}/renpy.cpp - ${RENPY_DIR}/pickle.cpp -) -target_link_libraries(renpy PRIVATE ZLIB::ZLIB) -target_include_directories(renpy PRIVATE ${ZSTR_INCLUDE_DIRS}) - -add_library(rpgmaker SHARED src/dll.cpp src/archive.cpp src/modules/rpgmaker/rpgmaker.cpp) - -add_library(zanzarah SHARED src/dll.cpp src/archive.cpp src/modules/zanzarah/zanzarah.cpp) - -foreach (module IN LISTS ALL_MODULES) - set_target_properties(${module} PROPERTIES SUFFIX ".so" PREFIX "" LINK_FLAGS "/DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/modules/${module}/${module}.def") -endforeach () - -# === CTest === - -find_package(Catch2 REQUIRED) -find_package(nlohmann_json REQUIRED) -find_package(xxHash CONFIG REQUIRED) -add_executable(tests - src/tests/framework/observer.cpp - src/tests/framework/testcase.cpp - src/tests/renpy.cpp - src/tests/rpgmaker.cpp - src/tests/zanzarah.cpp -) -target_link_libraries(tests PRIVATE Catch2::Catch2WithMain) -target_link_libraries(tests PRIVATE nlohmann_json::nlohmann_json) -target_link_libraries(tests PRIVATE xxHash::xxhash) -target_link_libraries(tests PRIVATE ${ALL_MODULES}) -include(CTest) -include(Catch) -catch_discover_tests(tests) - -# === CPack === - -file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/docs_temp/thirdparty) -file(COPY ${CMAKE_SOURCE_DIR}/licenses/ DESTINATION ${CMAKE_BINARY_DIR}/docs_temp/thirdparty) - -string(TIMESTAMP TODAY "%Y-%m-%d") -foreach (MODULE IN LISTS RELEASED_MODULES) - install(TARGETS ${MODULE} RUNTIME DESTINATION . COMPONENT ${MODULE}) - install(FILES ${CMAKE_SOURCE_DIR}/src/modules/${MODULE}/observer_user.ini DESTINATION . COMPONENT ${MODULE}) - - install(FILES ${CMAKE_SOURCE_DIR}/LICENSE.txt DESTINATION docs RENAME license.txt COMPONENT ${MODULE}) - install(DIRECTORY ${CMAKE_BINARY_DIR}/docs_temp/ DESTINATION docs COMPONENT ${MODULE}) - - install(FILES "$" DESTINATION . COMPONENT ${MODULE}_pdb) - - string(TOUPPER ${MODULE} MODULE_UPPER) - set(CPACK_ARCHIVE_${MODULE_UPPER}_FILE_NAME "${MODULE}-${TODAY}-${OBSERVER_ARCHITECTURE}-dll") - set(CPACK_ARCHIVE_${MODULE_UPPER}_PDB_FILE_NAME "${MODULE}-${TODAY}-${OBSERVER_ARCHITECTURE}-pdb") -endforeach () - -list(TRANSFORM RELEASED_MODULES APPEND "_pdb" OUTPUT_VARIABLE ALL_COMPONENTS) -list(PREPEND ALL_COMPONENTS ${RELEASED_MODULES}) - -set(CPACK_GENERATOR ZIP) -set(CPACK_ARCHIVE_COMPONENT_INSTALL ON) -set(CPACK_COMPONENTS_ALL ${ALL_COMPONENTS}) - -include(CPack) \ No newline at end of file diff --git a/CMakePresets.json b/CMakePresets.json deleted file mode 100644 index cdb22cd..0000000 --- a/CMakePresets.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "version": 3, - "configurePresets": [ - { - "hidden": true, - "name": "default", - "generator": "Ninja", - "vendor": { - "jetbrains.com/clion": { - "toolchain": "Visual Studio" - } - } - }, - { - "name": "x64-debug", - "inherits": "default", - "binaryDir": "${sourceDir}/build/x64-debug", - "architecture": { - "value": "x64", - "strategy": "external" - }, - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug", - "OBSERVER_ARCHITECTURE": "x64" - } - }, - { - "name": "x64-release", - "inherits": "default", - "binaryDir": "${sourceDir}/build/x64-release", - "architecture": { - "value": "x64", - "strategy": "external" - }, - "cacheVariables": { - "CMAKE_BUILD_TYPE": "RelWithDebInfo", - "OBSERVER_ARCHITECTURE": "x64" - } - }, - { - "name": "x86-debug", - "inherits": "default", - "binaryDir": "${sourceDir}/build/x86-debug", - "architecture": { - "value": "Win32", - "strategy": "external" - }, - "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug", - "OBSERVER_ARCHITECTURE": "x86" - } - }, - { - "name": "x86-release", - "inherits": "default", - "binaryDir": "${sourceDir}/build/x86-release", - "architecture": { - "value": "Win32", - "strategy": "external" - }, - "cacheVariables": { - "CMAKE_BUILD_TYPE": "RelWithDebInfo", - "OBSERVER_ARCHITECTURE": "x86" - } - } - ] -} diff --git a/README.md b/README.md index 5063a2e..1afda70 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,6 @@ specific files as needed without having to unpack the entire archive. | [lazyhamster/Observer](https://github.com/lazyhamster/Observer) | [LGPL-3.0](licenses/Observer.txt) | | [Cyan4973/xxHash](https://github.com/Cyan4973/xxHash) | [BSD-2-Clause](licenses/xxHash.txt) | | [zlib](https://zlib.net) | [zlib](licenses/zlib.txt) | -| [mateidavid/zstr](https://github.com/mateidavid/zstr) | [MIT](licenses/zstr.txt) | ## Sources of inspiration @@ -79,14 +78,29 @@ specific files as needed without having to unpack the entire archive. | [birkenfeld/serde-pickle](https://github.com/birkenfeld/serde-pickle) | [MIT](licenses/serde-pickle.txt) | | [Shizmob/rpatool](https://github.com/Shizmob/rpatool) | [WTFPL](licenses/rpatool.txt) | | [zanzapak](https://aluigi.altervista.org/papers.htm#others-file) | [GPL-3.0](licenses/zanzapak.txt) | +| [pg83/ix](https://github.com/pg83/ix/tree/66726a904152246fbef8b27e26e878840f6d7fb7) | [MIT](licenses/IX.txt) | ## Building from Source ### Prerequisites -- **Visual Studio 2017** compiler -- **CLion** and/or **CMake** (version 3.31+) -- **vcpkg** - -You can open the included CLion project directly and build through the IDE or use CMake manually to generate the build -files, then compile using the Visual Studio compiler. +- Visual Studio Build Tools 2022 with the v143 MSVC tools for x86/x64 and ARM64, Spectre-mitigated libraries, and a + Windows 11 SDK +- PowerShell 7.4 or newer +- [uv](https://docs.astral.sh/uv/) for the exact-pinned Python build-driver environment +- vcpkg available on `PATH` or through `VCPKG_ROOT` + +No IDE, Visual Studio developer prompt, global vcpkg integration, or repository-level CMake generation is required. +From a normal Windows console: + +```powershell +uv sync --project build --frozen +.\build.ps1 doctor +.\build.ps1 build -Arch all -Config Release +.\build.ps1 test -Arch x86,x64 -Config Debug +.\build.ps1 package -Arch all +``` + +The build restores pinned static dependencies and produces self-contained `/MT` modules for x86, x64, and ARM64. +See [the build-system documentation](docs/build-system.md) for analysis, coverage, sanitizer, fuzzing, binary-audit, and +packaging commands. diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..493a877 --- /dev/null +++ b/build.cmd @@ -0,0 +1,2 @@ +@echo off +pwsh.exe -NoLogo -NoProfile -File "%~dp0build.ps1" %* diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..9daa2e1 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,9 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$buildProject = Join-Path $PSScriptRoot 'build' +$env:UV_CACHE_DIR = Join-Path $buildProject '.uv-cache' +& uv run --project $buildProject --frozen --no-sync python (Join-Path $buildProject 'main.py') @args +exit $LASTEXITCODE diff --git a/build/ObserverConfiguration.props b/build/ObserverConfiguration.props new file mode 100644 index 0000000..bd80ca8 --- /dev/null +++ b/build/ObserverConfiguration.props @@ -0,0 +1,15 @@ + + + + + true + $(ObserverConfigurationType) + true + false + v143 + ClangCL + Unicode + true + Spectre + + diff --git a/build/ObserverFuzz.props b/build/ObserverFuzz.props new file mode 100644 index 0000000..c0c455c --- /dev/null +++ b/build/ObserverFuzz.props @@ -0,0 +1,14 @@ + + + + + Console + $(LLVMRuntimeDir)\clang_rt.asan-x86_64.lib;$(LLVMRuntimeDir)\clang_rt.asan_cxx-x86_64.lib;%(AdditionalDependencies) + /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.fuzzer-x86_64.lib" /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.asan-x86_64.lib" /WHOLEARCHIVE:"$(LLVMRuntimeDir)\clang_rt.asan_cxx-x86_64.lib" /INFERASANLIBS:NO %(AdditionalOptions) + + + + + + diff --git a/build/ObserverNativeAnalysis.ruleset b/build/ObserverNativeAnalysis.ruleset new file mode 100644 index 0000000..d5b8986 --- /dev/null +++ b/build/ObserverNativeAnalysis.ruleset @@ -0,0 +1,6 @@ + + + + diff --git a/build/ObserverProject.props b/build/ObserverProject.props new file mode 100644 index 0000000..1abcf68 --- /dev/null +++ b/build/ObserverProject.props @@ -0,0 +1,87 @@ + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\')) + $(RepositoryRoot)out\work\manual-msbuild\ + x86 + x64 + arm64 + observer-$(PlatformMoniker)-windows-static + observer-$(PlatformMoniker)-windows-static-asan + true + + false + + false + $(RepositoryRoot) + + $(ArtifactsRoot)vcpkg_installed\$(PlatformMoniker)\ + $(ArtifactsRoot)vcpkg_installed\$(PlatformMoniker)-asan\ + false + --overlay-triplets=$(RepositoryRoot)build\vcpkg\triplets + $(ArtifactsRoot)bin\$(PlatformMoniker)\$(Configuration)\ + $(ArtifactsRoot)obj\$(PlatformMoniker)\$(Configuration)\$(ProjectName)\ + $(OutDir) + $(ProjectName) + true + false + true + true + $(RepositoryRoot)build\ObserverNativeAnalysis.ruleset + $(ProjectName) + true + + + + + stdcpp23 + stdcpplatest + Level4 + true + true + true + TurnOffAllWarnings + true + true + false + true + Guard + true + $(ObserverAnalysisReportDirectory)\$(PlatformMoniker)\$(ObserverAnalysisReportName).sarif + $(ObserverAnalysisReportPath) + Sync + MultiThreaded + MultiThreadedDebug + OldStyle + EnableFastChecks + Default + false + Disabled + MaxSpeed + true + true + $(RepositoryRoot)src;%(AdditionalIncludeDirectories) + NOMINMAX;%(PreprocessorDefinitions) + /utf-8 /Zc:__cplusplus %(AdditionalOptions) + /sourceDependencies "$(ObserverSourceDependenciesPath)" %(AdditionalOptions) + /clang:-MJ"$(ObserverClangCommandPath)" %(AdditionalOptions) + /clang:-fprofile-instr-generate /clang:-fcoverage-mapping %(AdditionalOptions) + /fsanitize=address %(AdditionalOptions) + /clang:-fsanitize=undefined /clang:-fno-sanitize-recover=all %(AdditionalOptions) + /clang:-fsanitize=fuzzer,address %(AdditionalOptions) + + + true + true + true + true + true + Guard + true + true + true + UseLinkTimeCodeGeneration + $(LLVMRuntimeDir)\clang_rt.ubsan_standalone-x86_64.lib;$(LLVMRuntimeDir)\clang_rt.ubsan_standalone_cxx-x86_64.lib;%(AdditionalDependencies) + + + diff --git a/build/ObserverProjectConfigurations.props b/build/ObserverProjectConfigurations.props new file mode 100644 index 0000000..73b8947 --- /dev/null +++ b/build/ObserverProjectConfigurations.props @@ -0,0 +1,19 @@ + + + + DebugWin32 + Debugx64 + DebugARM64 + ReleaseWin32 + Releasex64 + ReleaseARM64 + CoverageWin32 + Coveragex64 + CoverageARM64 + ASanWin32 + ASanx64 + UBSanx64 + FuzzWin32 + Fuzzx64 + + diff --git a/build/PSScriptAnalyzerSettings.psd1 b/build/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..a6b326a --- /dev/null +++ b/build/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,6 @@ +@{ + Severity = @('Error', 'Warning') + ExcludeRules = @( + 'PSAvoidUsingWriteHost' + ) +} diff --git a/build/core/__init__.py b/build/core/__init__.py new file mode 100644 index 0000000..db0d63b --- /dev/null +++ b/build/core/__init__.py @@ -0,0 +1 @@ +"""Core primitives for the local ObserverModules build graph.""" diff --git a/build/core/binary_audit.py b/build/core/binary_audit.py new file mode 100644 index 0000000..e51a011 --- /dev/null +++ b/build/core/binary_audit.py @@ -0,0 +1,131 @@ +"""Release PE and BinSkim policy shared by fine-grained audit leaves.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +from collections.abc import Sequence + + +class AuditError(RuntimeError): + pass + + +_MACHINES = { + "x86": r"14C machine \(x86\)", + "x64": r"8664 machine \(x64\)", + "arm64": r"AA64 machine \(ARM64\)", +} +_ALLOWED_DLLS = { + "advapi32.dll", + "bcrypt.dll", + "kernel32.dll", + "ntdll.dll", + "ole32.dll", + "oleaut32.dll", + "shell32.dll", + "shlwapi.dll", + "user32.dll", +} +_FORBIDDEN_DLL = re.compile( + r"^(?:vcruntime|msvcp|ucrtbase|api-ms-win-crt-|ext-ms-win-crt-|zlib|zstd|xxhash|clang_rt\.).*\.dll$", + re.IGNORECASE, +) + + +def require_release_pe( + architecture: str, headers: str, dependents: str, exports: str +) -> None: + try: + machine = _MACHINES[architecture] + except KeyError as error: + raise AuditError(f"unsupported machine architecture: {architecture}") from error + if re.search(machine, headers) is None: + raise AuditError(f"wrong PE machine for {architecture}") + + dependencies = { + match.group(1) + for line in dependents.splitlines() + if (match := re.fullmatch(r"\s+([A-Za-z0-9._-]+\.dll)\s*", line)) + } + unexpected = sorted( + dependency + for dependency in dependencies + if _FORBIDDEN_DLL.match(dependency) + or ( + dependency.casefold() not in _ALLOWED_DLLS + and re.match(r"^(?:api|ext)-ms-win-.*\.dll$", dependency, re.IGNORECASE) is None + ) + ) + if unexpected: + raise AuditError("unexpected DLL dependencies: " + ", ".join(unexpected)) + + actual_exports = { + match.group(1) + for line in exports.splitlines() + if (match := re.match(r"^\s+\d+\s+[0-9A-F]+\s+[0-9A-F]+\s+(\S+)", line)) + } + expected_exports = {"LoadSubModule", "UnloadSubModule"} + if actual_exports != expected_exports: + raise AuditError("unexpected exports: " + ", ".join(sorted(actual_exports))) + + +def require_clean_binskim(document: dict[str, object]) -> None: + findings = [] + for run in document.get("runs", []): + rules = { + rule["id"]: rule.get("defaultConfiguration", {}).get("level", "warning") + for rule in run.get("tool", {}).get("driver", {}).get("rules", []) + } + for result in run.get("results", []): + rule = result.get("ruleId", "") + level = result.get("level", rules.get(rule, "warning")) + if level in {"warning", "error"} and not (level == "warning" and rule == "BA2027"): + findings.append(f"{level}:{rule}") + if findings: + raise AuditError("BinSkim unapproved findings: " + ", ".join(findings)) + + +def _run_binskim(tool: Path, binary: Path) -> None: + raw_output = os.environ.get("OBSERVER_OUT_DIR") + if not raw_output or not (output_root := Path(raw_output)).is_dir(): + raise AuditError("OBSERVER_OUT_DIR must be an existing directory") + report = output_root / "binskim.sarif" + argv = [ + str(tool), "analyze", str(binary), "--level", "Error;Warning", "--kind", "Fail", + "--local-symbol-directories", str(binary.parent), "--output", str(report), + "--log", "ForceOverwrite", "--quiet", "--disable-telemetry", + ] + subprocess.run(argv, check=True) + if not report.is_file(): + raise AuditError("BinSkim did not produce binskim.sarif") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + pe = commands.add_parser("pe") + for argument in ("architecture", "headers", "dependents", "exports"): + pe.add_argument(argument) + report = commands.add_parser("binskim") + report.add_argument("report") + run = commands.add_parser("run-binskim") + run.add_argument("tool") + run.add_argument("binary") + args = parser.parse_args(argv) + if args.command == "pe": + texts = [Path(getattr(args, name)).read_text(encoding="utf-8-sig") for name in ("headers", "dependents", "exports")] + require_release_pe(args.architecture, *texts) + elif args.command == "binskim": + require_clean_binskim(json.loads(Path(args.report).read_text(encoding="utf-8-sig"))) + else: + _run_binskim(Path(args.tool), Path(args.binary)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/clang_dependencies.py b/build/core/clang_dependencies.py new file mode 100644 index 0000000..28579af --- /dev/null +++ b/build/core/clang_dependencies.py @@ -0,0 +1,203 @@ +"""Resolve clang-cl translation-unit inputs through ``clang-scan-deps``.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +import json +import os +from pathlib import Path +import subprocess +from typing import Any + + +class ClangDependencyError(RuntimeError): + pass + + +def _file(path: Path, label: str) -> Path: + try: + resolved = path.resolve(strict=True) + except OSError as error: + raise ClangDependencyError(f"invalid clang dependency {label}: {path}") from error + if not resolved.is_file(): + raise ClangDependencyError(f"invalid clang dependency {label}: {path}") + return resolved + + +def load_compile_command(command: Path, source: Path, compiler: Path) -> dict[str, Any]: + """Load one clang ``-MJ`` fragment and remove its capture-only argument.""" + + expected_source, expected_compiler = _file(source, "source"), _file(compiler, "compiler") + try: + content = command.read_text(encoding="utf-8-sig").rstrip() + document = json.loads(content[:-1] if content.endswith(",") else content) + except (OSError, json.JSONDecodeError) as error: + raise ClangDependencyError(f"invalid clang compilation-command JSON: {command}") from error + if not isinstance(document, dict): + raise ClangDependencyError("invalid clang compilation-command JSON object") + try: + directory = Path(document["directory"]).resolve(strict=True) + except (KeyError, OSError, TypeError) as error: + raise ClangDependencyError("invalid clang compilation-command directory") from error + if not directory.is_dir(): + raise ClangDependencyError("invalid clang compilation-command directory") + try: + reported_source = Path(document["file"]) + reported_source = (directory / reported_source).resolve(strict=True) + except (KeyError, OSError, TypeError) as error: + raise ClangDependencyError("invalid clang compilation-command source") from error + if reported_source != expected_source: + raise ClangDependencyError("clang compilation-command source mismatch") + arguments = document.get("arguments") + if not isinstance(arguments, list) or not arguments or not all( + isinstance(item, str) for item in arguments + ): + raise ClangDependencyError("invalid clang compilation-command arguments") + try: + reported_compiler = Path(arguments[0]).resolve(strict=True) + except OSError as error: + raise ClangDependencyError("invalid clang compilation-command compiler") from error + if reported_compiler != expected_compiler: + raise ClangDependencyError("clang compilation-command compiler mismatch") + return { + **document, + "directory": str(directory), + "file": str(expected_source), + "arguments": [ + str(expected_compiler), + *(item for item in arguments[1:] if not item.casefold().startswith("/clang:-mj")), + ], + } + + +def dependency_manifest(source: Path, document: object) -> dict[str, object]: + """Convert experimental-full scanner JSON to the existing canonical manifest.""" + + expected_source = _file(source, "source") + try: + if not isinstance(document, dict): + raise TypeError + units = document["translation-units"] + if not isinstance(units, list) or not units: + raise TypeError + dependencies: list[Path] = [] + for unit in units: + commands = unit["commands"] + if not isinstance(commands, list) or not commands: + raise TypeError + for command in commands: + file_dependencies = command["file-deps"] + if not isinstance(file_dependencies, list) or not all( + isinstance(item, str) for item in file_dependencies + ): + raise TypeError + for item in file_dependencies: + path = Path(item) + if not path.is_absolute(): + raise TypeError + resolved = path.resolve(strict=True) + if not resolved.is_file(): + raise OSError + dependencies.append(resolved) + except (KeyError, OSError, TypeError) as error: + raise ClangDependencyError("invalid clang-scan-deps scan output") from error + if expected_source not in dependencies: + raise ClangDependencyError("invalid clang-scan-deps scan output: source is absent") + unique = { + os.path.normcase(str(path)): str(path) + for path in dependencies + if path != expected_source + } + return { + "Data": { + "Source": str(expected_source), + "Includes": sorted(unique.values(), key=str.casefold), + } + } + + +def scan_dependencies( + source: Path, command: Path, scanner: Path, compiler: Path, build_directory: Path +) -> dict[str, object]: + """Run the exact scanner over the exact clang-cl command captured by MSBuild.""" + + scanner = _file(scanner, "scanner") + compilation = load_compile_command(command, source, compiler) + try: + build_directory = build_directory.resolve(strict=True) + except OSError as error: + raise ClangDependencyError( + f"invalid clang dependency build directory: {build_directory}" + ) from error + if not build_directory.is_dir(): + raise ClangDependencyError( + f"invalid clang dependency build directory: {build_directory}" + ) + database = build_directory / "compile_commands.json" + try: + database.write_text( + json.dumps([compilation], ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + argv = [ + str(scanner), + "-format=experimental-full", + f"-compilation-database={database}", + "-j", + "1", + ] + result = subprocess.run( + argv, + cwd=compilation["directory"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + except OSError as error: + raise ClangDependencyError(f"failed to launch clang-scan-deps: {scanner}") from error + if result.returncode: + detail = result.stderr.strip().splitlines()[-1] if result.stderr.strip() else "no diagnostic" + raise ClangDependencyError( + f"clang-scan-deps failed ({result.returncode}): {detail[:400]}" + ) + try: + document = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise ClangDependencyError("invalid clang-scan-deps JSON output") from error + return dependency_manifest(source, document) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + scan = commands.add_parser("scan") + scan.add_argument("source") + scan.add_argument("command_file") + scan.add_argument("scanner") + scan.add_argument("compiler") + args = parser.parse_args(argv) + raw_output = os.environ.get("OBSERVER_OUT_DIR") + if not raw_output or not (output := Path(raw_output)).is_dir(): + raise ClangDependencyError("OBSERVER_OUT_DIR must be an existing directory") + raw_build = os.environ.get("OBSERVER_BUILD_DIR") + if not raw_build or not (build := Path(raw_build)).is_dir(): + raise ClangDependencyError("OBSERVER_BUILD_DIR must be an existing directory") + document = scan_dependencies( + Path(args.source), + Path(args.command_file), + Path(args.scanner), + Path(args.compiler), + build, + ) + (output / "dependencies.json").write_text( + json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/clean.py b/build/core/clean.py new file mode 100644 index 0000000..546d3b6 --- /dev/null +++ b/build/core/clean.py @@ -0,0 +1,215 @@ +"""Conservative cleanup of the exact repository-local generated output tree.""" + +from __future__ import annotations + +import argparse +from collections.abc import Collection, Sequence +import os +from pathlib import Path +import shutil +import stat + +from filelock import FileLock, Timeout + +from core.paths import BuildPaths, PathSafetyError + + +_MODES = ("all", "stale-work") + + +class CleanError(RuntimeError): + """Generated output cannot be proven safe and inactive.""" + + +def _tree(paths: BuildPaths, root: Path) -> None: + for directory, directories, files in root.walk(follow_symlinks=False): + for name in (*directories, *files): + current = paths.require_confined(directory / name, paths.output_root) + mode = os.lstat(current).st_mode + if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode): + raise CleanError(f"unsupported output entry type: {current}") + + +def _validate(paths: BuildPaths) -> bool: + output = paths.require_confined(paths.output_root, paths.output_root) + try: + mode = os.lstat(output).st_mode + except FileNotFoundError: + return False + if not stat.S_ISDIR(mode): + raise CleanError(f"output root is not a directory: {output}") + if output.resolve(strict=True) != paths.repository / "out": + raise CleanError(f"output root does not resolve to repository/out: {output}") + for child in output.iterdir(): + current = paths.require_confined(child, paths.output_root) + if current.name not in {"cas", "work"}: + raise CleanError(f"unexpected output entry: {current}") + if not stat.S_ISDIR(os.lstat(current).st_mode): + raise CleanError(f"output entry is not a directory: {current}") + _tree(paths, output) + return True + + +def _runs(paths: BuildPaths) -> tuple[Path, ...]: + result = [] + for entry in sorted(paths.work_root.iterdir()): + if entry == paths.locks_root: + continue + if not entry.is_dir(): + raise CleanError(f"unexpected work entry: {entry}") + try: + result.append(paths.run_work(entry.name)) + except PathSafetyError as error: + raise CleanError(f"unexpected work entry: {entry}") from error + return tuple(result) + + +def _require_inactive(paths: BuildPaths) -> tuple[Path, ...]: + inactive = [] + for entry in sorted(paths.locks_root.iterdir()): + if entry == paths.coordination_lock(): + continue + if not entry.is_file(): + raise CleanError(f"unexpected lock entry: {entry}") + try: + if entry.suffix == ".lock": + paths.lock(entry.stem) + elif entry.name.startswith("run-") and entry.suffix == ".lease": + paths.lease(entry.name[4:-6]) + else: + raise PathSafetyError("unknown lock name") + except PathSafetyError as error: + raise CleanError(f"unexpected lock entry: {entry}") from error + lock = FileLock(entry, timeout=0, fallback_to_soft=False, preserve_lock_file=True) + try: + with lock: + pass + except Timeout as error: + raise CleanError(f"active build lock: {entry}") from error + inactive.append(entry) + return tuple(inactive) + + +def _require_no_active_runs(paths: BuildPaths, owned_lease: Path | None) -> None: + owned_active = owned_lease is None + for entry in sorted(paths.locks_root.iterdir()): + if not (entry.name.startswith("run-") and entry.suffix == ".lease"): + continue + try: + paths.lease(entry.name[4:-6]) + except PathSafetyError as error: + raise CleanError(f"unexpected run lease: {entry}") from error + lock = FileLock( + entry, timeout=0, fallback_to_soft=False, preserve_lock_file=True + ) + try: + with lock: + if entry == owned_lease: + raise CleanError(f"owned run lease is not active: {entry}") + except Timeout as error: + if entry == owned_lease: + owned_active = True + continue + raise CleanError(f"active build lease: {entry}") from error + if not owned_active: + raise CleanError(f"owned run lease is missing: {owned_lease}") + + +def sweep_cas( + repository: Path | str, live_uids: Collection[str], *, owned_run_id: str | None = None +) -> tuple[Path, ...]: + """Remove unlocked canonical CAS entries absent from the explicit live set.""" + + paths = BuildPaths(repository) + owned_lease = paths.lease(owned_run_id) if owned_run_id is not None else None + live = frozenset(live_uids) + for uid in live: + paths.cas(uid) + if not _validate(paths): + return () + paths.work_root.mkdir(exist_ok=True) + paths.locks_root.mkdir(exist_ok=True) + coordination = FileLock( + paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + try: + with coordination: + if not _validate(paths) or not paths.cas_root.exists(): + return () + _require_no_active_runs(paths, owned_lease) + removed = [] + for entry in sorted(paths.cas_root.iterdir()): + uid = entry.name + if ( + len(uid) != 32 + or any(character not in "0123456789abcdef" for character in uid) + or uid in live + ): + continue + candidate = paths.cas(uid) + node = FileLock( + paths.lock(uid), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + try: + with node: + candidate = paths.cas(uid) + shutil.rmtree(candidate.entry) + except Timeout: + continue + removed.append(candidate.entry) + return tuple(removed) + except Timeout as error: + raise CleanError("active build coordination lock") from error + + +def clean(repository: Path | str, mode: str = "all") -> tuple[Path, ...]: + """Remove all output or inactive work, including legacy CAS entry names.""" + + if mode not in _MODES: + raise ValueError(f"unsupported clean mode: {mode}") + paths = BuildPaths(repository) + if not _validate(paths): + return () + paths.work_root.mkdir(exist_ok=True) + paths.locks_root.mkdir(exist_ok=True) + coordination = FileLock( + paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + try: + with coordination: + if not _validate(paths): + return () + runs = _runs(paths) + inactive = _require_inactive(paths) + if mode == "all": + removed = [] + if paths.cas_root.exists(): + removed.append(paths.cas_root) + shutil.rmtree(paths.cas_root) + for run in runs: + shutil.rmtree(run) + for lock in inactive: + lock.unlink() + return tuple(removed) + runs + inactive + for run in runs: + shutil.rmtree(run) + return runs + except Timeout as error: + raise CleanError("active build coordination lock") from error + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="observer-build clean") + parser.add_argument("repository", type=Path) + parser.add_argument("--mode", choices=_MODES, default="all") + args = parser.parse_args(argv) + for removed in clean(args.repository, args.mode): + print(removed) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/cpp_coverage.py b/build/core/cpp_coverage.py new file mode 100644 index 0000000..c757e67 --- /dev/null +++ b/build/core/cpp_coverage.py @@ -0,0 +1,68 @@ +"""Semantic policy for LLVM coverage reports.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from collections.abc import Mapping, Sequence + + +class CoverageError(RuntimeError): + pass + + +def _metric(totals: Mapping[str, object], name: str) -> tuple[int, int]: + try: + metric = totals[name] + if not isinstance(metric, Mapping): + raise TypeError + count, covered = metric["count"], metric["covered"] + if ( + isinstance(count, bool) + or not isinstance(count, int) + or isinstance(covered, bool) + or not isinstance(covered, int) + or count < 0 + or covered < 0 + or covered > count + ): + raise TypeError + return count, covered + except (KeyError, TypeError) as error: + raise CoverageError("malformed LLVM coverage totals") from error + + +def require_full_coverage(document: Mapping[str, object]) -> None: + """Require nonempty, exact 100% first-party line and branch coverage.""" + + try: + data = document["data"] + if not isinstance(data, list) or len(data) != 1 or not isinstance(data[0], Mapping): + raise TypeError + totals = data[0]["totals"] + if not isinstance(totals, Mapping): + raise TypeError + except (KeyError, TypeError) as error: + raise CoverageError("malformed LLVM coverage report") from error + + for name in ("lines", "branches"): + count, covered = _metric(totals, name) + if count == 0: + raise CoverageError(f"no first-party {name} in LLVM coverage report") + if covered != count: + raise CoverageError(f"first-party {name} coverage is {covered}/{count}, required 100%") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + gate = commands.add_parser("gate") + gate.add_argument("report") + args = parser.parse_args(argv) + require_full_coverage(json.loads(Path(args.report).read_text(encoding="utf-8-sig"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/doctor.py b/build/core/doctor.py new file mode 100644 index 0000000..beaf7d9 --- /dev/null +++ b/build/core/doctor.py @@ -0,0 +1,79 @@ +"""Read-only, fail-soft discovery of complete local build prerequisites.""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path +import sys +import tomllib + +from core.quality_tools import discover_quality_tools, resolve_sanitizer_runtimes +from core.source_tools import discover_source_tools +from core.toolchain import discover_msvc_toolchain + +_MSVC = (("msbuild_version", "MSBuild"), ("vc_tools_version", "MSVC"), + ("clang_tidy_version", "LLVM"), ("windows_sdk_version", "SDK")) +_SOURCE = (("pwsh_version", "PowerShell"), ("clang_format_version", "clang-format"), + ("cppcheck_version", "Cppcheck"), ("psscriptanalyzer_version", "PSScriptAnalyzer")) + +@dataclass(frozen=True, slots=True) +class Probe: + name: str + status: str + detail: str + +def _python_version() -> str: + config = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8")) + required = config["project"]["requires-python"] + actual = ".".join(map(str, sys.version_info[:3])) + if required != f"=={actual}": + raise RuntimeError(f"requires {required}, running {actual}") + return actual + +def _versions(value: object, fields: tuple[tuple[str, str], ...]) -> str: + identity = dict(value.identity) # type: ignore[attr-defined] + return ", ".join(f"{label}={identity[key]}" for key, label in fields) + +def _concise(error: Exception) -> str: + return " ".join(str(error).split()) or type(error).__name__ + +def doctor_report() -> tuple[Probe, ...]: + rows: list[Probe] = [] + + def probe(name: str, action: Callable[[], object], + detail: Callable[[object], str]) -> object | None: + try: + value = action() + rows.append(Probe(name, "OK", detail(value))) + return value + except Exception as error: # doctor must preserve the remaining independent probes + rows.append(Probe(name, "MISSING", _concise(error))) + return None + + def require_toolchain() -> object: + if toolchain is None: + raise RuntimeError("MSVC toolchain unavailable") + return toolchain + + probe("python", _python_version, str) + toolchain = probe("msvc", discover_msvc_toolchain, lambda value: _versions(value, _MSVC)) + probe("source-tools", lambda: discover_source_tools(require_toolchain()), + lambda value: _versions(value, _SOURCE)) + probe("quality-tools", lambda: discover_quality_tools(require_toolchain()), + lambda _value: "clang-cl, clang-scan-deps, llvm-cov, llvm-profdata, dumpbin, BinSkim, UMDH") + probe("sanitizer-runtimes", lambda: resolve_sanitizer_runtimes(require_toolchain()), + lambda _value: "ASan x86/x64, UBSan x64") + return tuple(rows) + +def main(argv: Sequence[str] | None = None) -> int: + argparse.ArgumentParser(prog="observer-build doctor").parse_args(argv) + report = doctor_report() + print("probe\tstatus\tdetail") + for item in report: + print(f"{item.name}\t{item.status}\t{item.detail}") + return int(any(item.status != "OK" for item in report)) + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/execute.py b/build/core/execute.py new file mode 100644 index 0000000..053ad97 --- /dev/null +++ b/build/core/execute.py @@ -0,0 +1,127 @@ +"""Demand execution adapted from pg83/ix (MIT) at commit 66726a9.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextlib import asynccontextmanager +from typing import AsyncContextManager + +from core.graph import Graph, GraphError, Node + + +class ExecutionError(RuntimeError): + """A node did not publish a complete cache entry.""" + + +CompletionPredicate = Callable[[Node], bool] +Runner = Callable[[Node], Awaitable[None]] +Publisher = Callable[[Node], None] +LockFactory = Callable[[Node], AsyncContextManager[object]] + + +@asynccontextmanager +async def _unlocked(_node: Node): + yield None + + +class Executor: + """Execute demanded ancestors once under named and shared-slot limits.""" + + def __init__( + self, + graph: Graph, + *, + is_complete: CompletionPredicate, + runner: Runner, + publish: Publisher, + acquire_lock: LockFactory = _unlocked, + ) -> None: + self._graph = graph + self._is_complete_hook = is_complete + self._runner = runner + self._publish = publish + self._acquire_lock = acquire_lock + self._visited: set[str] = set() + self._failed: dict[str, Exception] = {} + self._blocked: set[str] = set() + self._locks = {node.name: asyncio.Lock() for node in graph.nodes} + self._pools = { + name: asyncio.Semaphore(capacity) for name, capacity in graph.pools.items() + } + self._global = self._pools.get("slot") + self._used = False + + async def run(self, targets: tuple[str, ...] | None = None) -> None: + if self._used: + raise ExecutionError("an Executor is single-use") + self._used = True + requested = self._graph.targets if targets is None else tuple(targets) + if not requested: + raise GraphError("explicit target set must not be empty") + await self._visit_many(tuple(self._graph.node(name) for name in requested)) + if self._failed: + failures = tuple(sorted(self._failed.items())) + names = ", ".join(name for name, _error in failures) + group = ExceptionGroup( + f"{len(failures)} node(s) failed: {names}", + tuple(error for _name, error in failures), + ) + group.failed_nodes = tuple(name for name, _error in failures) # type: ignore[attr-defined] + raise group + + async def _visit(self, current: Node) -> None: + async with self._locks[current.name]: + if ( + current.name in self._visited + or current.name in self._failed + or current.name in self._blocked + ): + return + try: + if self._is_complete(current): + self._visited.add(current.name) + return + + async with self._acquire_lock(current): + if self._is_complete(current): + self._visited.add(current.name) + return + dependencies = self._graph.dependencies_of(current.name) + await self._visit_many(dependencies) + if any(dependency.name not in self._visited for dependency in dependencies): + self._blocked.add(current.name) + return + async with self._capacity(current): + await self._runner(current) + self._publish(current) + if not self._is_complete(current): + raise ExecutionError( + f"node {current.name!r} returned without complete output" + ) + self._visited.add(current.name) + except Exception as error: + self._failed[current.name] = error + + @asynccontextmanager + async def _capacity(self, current: Node): + pool = self._pools[current.pool] + async with pool: + if self._global is None or pool is self._global: + yield + else: + async with self._global: + yield + + def _is_complete(self, current: Node) -> bool: + result = self._is_complete_hook(current) + if not isinstance(result, bool): + raise ExecutionError( + f"completion predicate for node {current.name!r} did not return bool" + ) + return result + + async def _visit_many(self, nodes: tuple[Node, ...]) -> None: + async with asyncio.TaskGroup() as tasks: + for current in nodes: + tasks.create_task(self._visit(current)) diff --git a/build/core/graph.py b/build/core/graph.py new file mode 100644 index 0000000..fbc8015 --- /dev/null +++ b/build/core/graph.py @@ -0,0 +1,236 @@ +"""Small named DAG model adapted from pg83/ix (MIT) at commit 66726a9.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from graphlib import CycleError, TopologicalSorter +import re +from types import MappingProxyType +from typing import Mapping + + +class GraphError(ValueError): + """The graph is incomplete, ambiguous, or cyclic.""" + + +NODE_SLUG_MAX_LENGTH = 128 +_SLUG = re.compile(rf"[a-z0-9][a-z0-9._-]{{0,{NODE_SLUG_MAX_LENGTH - 1}}}") +_MD5_UID = re.compile(r"[0-9a-f]{32}") +_RESULT_ID = re.compile(r"[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)*") +_RESULT_KIND = re.compile(r"[a-z][a-z0-9._-]*") +_MEDIA_TYPE = re.compile(r"[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*") + + +def _text(value: object, description: str) -> str: + if not isinstance(value, str) or not value or "\0" in value: + raise GraphError(f"{description} must be a non-empty string without NUL") + return value + + +@dataclass(frozen=True, slots=True) +class Command: + """One literal process invocation; no field is shell-translated.""" + + argv: tuple[str, ...] + env: tuple[tuple[str, str], ...] = () + cwd: str | None = None + stdin: bytes = b"" + + def __post_init__(self) -> None: + argv = tuple(self.argv) + if not argv: + raise GraphError("command argv must not be empty") + for argument in argv: + if not isinstance(argument, str) or "\0" in argument: + raise GraphError("command argument must be a string without NUL") + + try: + env = tuple((key, value) for key, value in self.env) + except (TypeError, ValueError) as error: + raise GraphError("command environment must contain key/value pairs") from error + for key, value in env: + _text(key, "environment key") + if not isinstance(value, str) or "\0" in value: + raise GraphError("environment value must be a string without NUL") + folded_keys = [key.casefold() for key, _value in env] + if len(folded_keys) != len(set(folded_keys)): + raise GraphError("command environment contains duplicate keys") + if self.cwd is not None: + _text(self.cwd, "command cwd") + if type(self.stdin) is not bytes: + raise GraphError("command stdin must be exact bytes") + + object.__setattr__(self, "argv", argv) + object.__setattr__(self, "env", tuple(sorted(env, key=lambda pair: pair[0].casefold()))) + + +@dataclass(frozen=True, slots=True) +class Result: + """One stable logical result at a portable path below the node output.""" + + id: str + kind: str + media_type: str + relative_path: str + + def __post_init__(self) -> None: + if not isinstance(self.id, str) or _RESULT_ID.fullmatch(self.id) is None: + raise GraphError("result id must be a lowercase slash-separated identifier") + if not isinstance(self.kind, str) or _RESULT_KIND.fullmatch(self.kind) is None: + raise GraphError("result kind must be a lowercase identifier") + if not isinstance(self.media_type, str) or _MEDIA_TYPE.fullmatch(self.media_type) is None: + raise GraphError("result media type must be a canonical lowercase MIME type") + if not isinstance(self.relative_path, str): + raise GraphError("result path must be a portable relative path") + parts = self.relative_path.split("/") + if ( + not self.relative_path + or "\0" in self.relative_path + or "\\" in self.relative_path + or ":" in self.relative_path + or self.relative_path.startswith("/") + or any(part in {"", ".", ".."} for part in parts) + ): + raise GraphError("result path must be a portable relative path") + + +@dataclass(frozen=True, slots=True) +class Node: + """One cacheable command whose inputs name its direct dependency nodes.""" + + name: str + uid: str + pool: str + command: Command + inputs: tuple[str, ...] = () + results: tuple[Result, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or _SLUG.fullmatch(self.name) is None: + raise GraphError( + "node name must be a lowercase readable slug of at most " + f"{NODE_SLUG_MAX_LENGTH} ASCII characters" + ) + if not isinstance(self.uid, str) or _MD5_UID.fullmatch(self.uid) is None: + raise GraphError("node uid must be a 32-character lowercase MD5") + _text(self.pool, "node pool") + if not isinstance(self.command, Command): + raise GraphError("node command must be a Command") + inputs = tuple(self.inputs) + for dependency in inputs: + _text(dependency, "dependency name") + if len(inputs) != len(set(inputs)): + raise GraphError(f"node {self.name!r} contains duplicate dependencies") + results = tuple(self.results) + if any(not isinstance(result, Result) for result in results): + raise GraphError("node results must be Result instances") + result_ids = tuple(result.id for result in results) + if len(result_ids) != len(set(result_ids)): + raise GraphError(f"node {self.name!r} contains duplicate result ids") + object.__setattr__(self, "inputs", inputs) + object.__setattr__(self, "results", results) + + +@dataclass(frozen=True, slots=True) +class Graph: + """Validated DAG with readable node names and stable logical result ids.""" + + nodes: tuple[Node, ...] + targets: tuple[str, ...] + pools: Mapping[str, int] + _by_name: Mapping[str, Node] = field(init=False, repr=False, compare=False) + _by_result: Mapping[str, tuple[Node, Result]] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + nodes = tuple(self.nodes) + targets = tuple(self.targets) + pools = dict(self.pools) + if not nodes: + raise GraphError("graph must contain at least one node") + if not targets: + raise GraphError("graph must contain at least one target") + + by_name: dict[str, Node] = {} + by_result: dict[str, tuple[Node, Result]] = {} + for current in nodes: + if not isinstance(current, Node): + raise GraphError("graph nodes must be Node instances") + if current.name in by_name: + raise GraphError(f"duplicate node name: {current.name}") + by_name[current.name] = current + for result in current.results: + if result.id in by_result: + raise GraphError(f"duplicate result id: {result.id}") + by_result[result.id] = (current, result) + + for name, capacity in pools.items(): + _text(name, "pool name") + if isinstance(capacity, bool) or not isinstance(capacity, int) or capacity <= 0: + raise GraphError(f"invalid pool capacity for {name!r}: {capacity!r}") + for current in nodes: + if current.pool not in pools: + raise GraphError(f"node {current.name!r} uses unknown pool {current.pool!r}") + for dependency in current.inputs: + if dependency not in by_name: + raise GraphError( + f"node {current.name!r} has unknown dependency {dependency!r}" + ) + + for target in targets: + _text(target, "target node name") + if target not in by_name: + raise GraphError(f"unknown target node {target!r}") + if len(targets) != len(set(targets)): + raise GraphError("graph contains duplicate targets") + + dependencies = { + name: by_name[name].inputs for name in sorted(by_name) + } + try: + TopologicalSorter(dependencies).prepare() + except CycleError as error: + cycle = error.args[1] if len(error.args) > 1 else () + raise GraphError("dependency cycle: " + " -> ".join(cycle)) from error + + object.__setattr__(self, "nodes", nodes) + object.__setattr__(self, "targets", targets) + object.__setattr__(self, "pools", MappingProxyType(pools)) + object.__setattr__(self, "_by_name", MappingProxyType(by_name)) + object.__setattr__(self, "_by_result", MappingProxyType(by_result)) + + def node(self, name: str) -> Node: + try: + return self._by_name[name] + except KeyError as error: + raise GraphError(f"unknown node {name!r}") from error + + def dependencies_of(self, name: str) -> tuple[Node, ...]: + return tuple(self.node(dependency) for dependency in self.node(name).inputs) + + def result(self, result_id: str) -> tuple[Node, Result]: + try: + return self._by_result[result_id] + except KeyError as error: + raise GraphError(f"unknown result {result_id!r}") from error + + +def merge_graphs(*graphs: Graph) -> Graph: + """Union independent graphs while deduplicating byte-identical shared nodes.""" + + if not graphs: + raise GraphError("graph merge requires at least one graph") + nodes: dict[str, Node] = {} + targets: dict[str, None] = {} + pools: dict[str, int] = {} + for graph in graphs: + for current in graph.nodes: + previous = nodes.get(current.name) + if previous is not None and previous != current: + raise GraphError(f"conflicting node definition: {current.name}") + nodes.setdefault(current.name, current) + targets.update((name, None) for name in graph.targets) + for name, capacity in graph.pools.items(): + if name in pools and pools[name] != capacity: + raise GraphError(f"conflicting pool capacity for {name}") + pools[name] = capacity + return Graph(tuple(nodes.values()), tuple(targets), pools) diff --git a/build/core/host.py b/build/core/host.py new file mode 100644 index 0000000..19cde1d --- /dev/null +++ b/build/core/host.py @@ -0,0 +1,107 @@ +"""Windows host architecture and local test execution policy.""" + +from __future__ import annotations + +from dataclasses import dataclass +import platform + + +_MACHINE_ARCHITECTURES = { + "amd64": "x64", + "x86_64": "x64", + "arm64": "arm64", + "aarch64": "arm64", + "x86": "x86", + "i386": "x86", + "i686": "x86", +} +_RUNNABLE = { + "x86": frozenset({"x86"}), + "x64": frozenset({"x86", "x64"}), + "arm64": frozenset({"x86", "x64", "arm64"}), +} + + +@dataclass(frozen=True, slots=True) +class DeferredGate: + gate: str + architecture: str + reason: str + + +@dataclass(frozen=True, slots=True) +class VerifyRoute: + runnable: tuple[str, ...] + coverage: tuple[str, ...] + asan: tuple[str, ...] + ubsan: tuple[str, ...] + deferred: tuple[DeferredGate, ...] + + @property + def run_x64_specialists(self) -> bool: + return bool(self.ubsan) + + +def detect_host_architecture(machine: str | None = None) -> str: + value = platform.machine() if machine is None else machine + try: + return _MACHINE_ARCHITECTURES[value.casefold()] + except KeyError as error: + raise RuntimeError(f"unsupported Windows host architecture: {value}") from error + + +def runnable_architectures( + requested: tuple[str, ...], host_architecture: str | None = None +) -> tuple[str, ...]: + host = detect_host_architecture() if host_architecture is None else host_architecture + if host not in _RUNNABLE: + raise ValueError(f"unsupported host architecture: {host}") + unsupported = set(requested) - _RUNNABLE.keys() + if unsupported: + raise ValueError(f"unsupported requested architecture: {sorted(unsupported)[0]}") + return tuple(architecture for architecture in requested if architecture in _RUNNABLE[host]) + + +def require_runnable( + requested: tuple[str, ...], host_architecture: str | None = None +) -> tuple[str, ...]: + runnable = runnable_architectures(requested, host_architecture) + if runnable != requested: + missing = next(architecture for architecture in requested if architecture not in runnable) + host = detect_host_architecture() if host_architecture is None else host_architecture + raise RuntimeError(f"cannot run {missing} tests on {host} host") + return runnable + + +def verify_route( + requested: tuple[str, ...], host_architecture: str | None = None +) -> VerifyRoute: + """Route locally executable verify work and preserve explicit deferrals.""" + + runnable = runnable_architectures(requested, host_architecture) + missing = tuple(item for item in requested if item not in runnable) + deferred = [ + DeferredGate(gate, architecture, f"host cannot execute {architecture} {gate}") + for architecture in missing + for gate in ("tests", "package-runtime") + ] + specialists = ( + ("coverage", ("x64",)), + ("asan", ("x86", "x64")), + ("ubsan", ("x64",)), + ("leaks", ("x64",)), + ("fuzz", ("x64",)), + ) + deferred.extend( + DeferredGate(gate, architecture, f"host cannot execute {architecture} {gate}") + for gate, supported in specialists + for architecture in missing + if architecture in supported + ) + return VerifyRoute( + runnable, + tuple(item for item in runnable if item == "x64"), + tuple(item for item in runnable if item in {"x86", "x64"}), + tuple(item for item in runnable if item == "x64"), + tuple(deferred), + ) diff --git a/build/core/leak.py b/build/core/leak.py new file mode 100644 index 0000000..e96c4b1 --- /dev/null +++ b/build/core/leak.py @@ -0,0 +1,334 @@ +"""Small process-safe worker for fine-grained UMDH leak nodes.""" + +from __future__ import annotations + +from collections.abc import Sequence +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + +from filelock import FileLock +import psutil + + +MODES = ("operations", "lifecycle") +SCENARIOS = ( + "small-success", "malformed", "cancellation", "read-failure", "write-failure", + "large-metadata", "sparse-metadata", +) +BINARIES = ("leak-probe.exe", "renpy.so", "rpgmaker.so", "zanzarah.so") + + +class LeakError(RuntimeError): + pass + + +def _output() -> Path: + value = os.environ.get("OBSERVER_OUT_DIR") + if not value or not (output := Path(value)).is_dir(): + raise LeakError("OBSERVER_OUT_DIR must be an existing directory") + return output + + +def _json(name: str, value: object) -> None: + (_output() / name).write_text( + json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _exact(action: str, args: Sequence[str], count: int) -> tuple[str, ...]: + if len(args) != count: + raise LeakError(f"{action} expects {count} arguments") + return tuple(args) + + +def _file(value: str, name: str) -> Path: + path = Path(value) + if not path.is_file(): + raise LeakError(f"{name} was not found: {path}") + return path + + +def _selection(mode: str, scenario: str) -> None: + if mode not in MODES or scenario not in SCENARIOS: + raise LeakError(f"invalid leak selection: {mode}/{scenario}") + + +def _count(value: str, name: str, *, minimum: int = 1) -> int: + try: + result = int(value) + except ValueError as error: + raise LeakError(f"{name} must be an integer") from error + if result < minimum: + raise LeakError(f"{name} must be at least {minimum}") + return result + + +def _marker(lines: Sequence[str], marker: str) -> str: + prefix = f"OBSERVER_LEAK_PROBE|{marker}|" + found = [line for line in lines if line.startswith(prefix)] + if len(found) != 1: + raise LeakError(f"leak probe emitted {len(found)} {marker} markers") + return found[0] + + +def _ready(line: str, mode: str, scenario: str, pid: int | None = None) -> None: + expected = rf"^OBSERVER_LEAK_PROBE\|READY\|pid=([0-9]+)\|mode={re.escape(mode)}\|configuration=Release\|scenarios={re.escape(scenario)}$" + match = re.fullmatch(expected, line) + if match is None or (pid is not None and int(match.group(1)) != pid): + raise LeakError("leak READY marker does not match the requested process/selection") + + +def _setup(args: Sequence[str]) -> None: + sources = tuple(Path(value) for value in _exact("setup", args, len(BINARIES))) + output, evidence = _output(), [] + for name, source in zip(BINARIES, sources, strict=True): + if not source.is_file(): + raise LeakError(f"leak binary was not found: {source}") + destination = output / name + shutil.copyfile(source, destination) + for symbol in source.parent.glob("*.pdb"): + shutil.copyfile(symbol, output / symbol.name) + with destination.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + evidence.append({"name": name, "path": str(destination), "sha256": digest}) + _json("release-binaries.json", {"architecture": "x64", "configuration": "Release", "runtimeLibrary": "MT_StaticRelease", "binaries": evidence}) + + +def _preflight(args: Sequence[str]) -> None: + directory, mode, scenario = _exact("preflight", args, 3) + _selection(mode, scenario) + probe = _file(str(Path(directory) / BINARIES[0]), "leak probe") + command = [str(probe), "--automatic", "--mode", mode, "--scenario", scenario, "--warmup", "1", "--iterations", "1", "--windows", "3"] + result = subprocess.run(command, cwd=directory, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", errors="replace") + if result.returncode: + raise LeakError(f"leak preflight failed ({result.returncode}): {result.stdout}") + lines = result.stdout.splitlines() + ready = _marker(lines, "READY") + _ready(ready, mode, scenario) + _marker(lines, "DONE") + _json("preflight.json", {"mode": mode, "scenario": scenario, "ready": ready}) + + +def _read(process: psutil.Popen[str], marker: str, label: str = "") -> str: + assert process.stdout is not None + prefix = f"OBSERVER_LEAK_PROBE|{marker}|{label + '|' if label else ''}" + while line := process.stdout.readline(): + if line.rstrip("\r\n").startswith(prefix): + return line.rstrip("\r\n") + raise LeakError(f"leak probe ended before {marker} marker") + + +def _kill_tree(process: psutil.Popen[str]) -> None: + processes = [*process.children(recursive=True), process] + for current in reversed(processes): + try: + current.kill() + except psutil.NoSuchProcess: + pass + psutil.wait_procs(processes, timeout=5) + + +def _run_gflags(gflags: Path, image: str, flag: str | None = None) -> subprocess.CompletedProcess[str]: + command = [str(gflags), "/i", image] + if flag is not None: + command.append(flag) + return subprocess.run( + command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + encoding="utf-8", errors="replace", + ) + + +def _change_stack_traces(gflags: Path, image: str, enabled: bool) -> None: + flag = "+ust" if enabled else "-ust" + result = _run_gflags(gflags, image, flag) + if result.returncode: + raise LeakError(f"GFlags {flag} failed ({result.returncode}): {result.stdout}") + + +def _enable_stack_traces(gflags: Path, image: str) -> bool: + if not gflags.is_file(): + return False + current = _run_gflags(gflags, image) + if current.returncode: + raise LeakError(f"GFlags query failed ({current.returncode}): {current.stdout}") + match = re.search( + r"are:\s*([0-9A-Fa-f]{8}(?:\s*:\s*[0-9A-Fa-f]{8})*)\s*$", + current.stdout, + ) + if current.stdout.startswith("No Registry Settings for "): + flags = 0 + elif match is not None: + flags = 0 + for value in match.group(1).split(":"): + flags |= int(value.strip(), 16) + else: + raise LeakError(f"GFlags returned an unrecognized setting: {current.stdout}") + if flags & 0x1000: + return False + try: + _change_stack_traces(gflags, image, True) + except OSError as error: + if getattr(error, "winerror", None) != 740: + raise + return False + return True + + +def _snapshot( + umdh: Path, pid: int, destination: Path, baseline: bool, environment: dict[str, str] +) -> None: + result = subprocess.run( + [str(umdh), f"-p:{pid}", f"-f:{destination}"], + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + encoding="utf-8", + errors="replace", + ) + text = destination.read_text(encoding="utf-8", errors="replace") if destination.is_file() else "" + if baseline: + if result.returncode not in (0, 1) or (result.returncode == 1 and "enabled allocation stack collection" not in text): + raise LeakError(f"UMDH could not prime stack collection ({result.returncode}): {result.stdout}") + elif result.returncode or re.search(r"didn't find any allocations|database is full|stack trace database.*full", text, re.I) or "BackTrace" not in text: + raise LeakError(f"UMDH snapshot is unusable: {destination}") + + +def _capture(args: Sequence[str]) -> None: + directory, umdh_value, mode, scenario, warmup_value, iterations_value, windows_value = _exact("capture", args, 7) + _selection(mode, scenario) + probe, umdh = _file(str(Path(directory) / BINARIES[0]), "leak probe"), _file(umdh_value, "UMDH") + gflags = umdh.with_name("gflags.exe") + warmup, iterations, windows = _count(warmup_value, "warmup"), _count(iterations_value, "iterations"), _count(windows_value, "windows", minimum=3) + output, snapshot_dir = _output(), _output() / "snapshots" + snapshot_dir.mkdir() + environment = os.environ | {"_NT_SYMBOL_PATH": directory, "OANOCACHE": "1"} + command = [str(probe), "--mode", mode, "--scenario", scenario, "--warmup", str(warmup), "--iterations", str(iterations), "--windows", str(windows)] + error_path = output / "probe.stderr.log" + process: psutil.Popen[str] | None = None + with error_path.open("w+", encoding="utf-8") as errors: + try: + lock = FileLock(Path(tempfile.gettempdir()) / "observer-modules-gflags.lock") + with lock: + changed = _enable_stack_traces(gflags, probe.name) + try: + process = psutil.Popen(command, cwd=directory, env=environment, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=errors, text=True, encoding="utf-8", errors="replace") + finally: + if changed: + _change_stack_traces(gflags, probe.name, False) + assert process is not None + _ready(_read(process, "READY"), mode, scenario, process.pid) + for label in ("baseline", *(f"window-{index}" for index in range(1, windows + 1))): + line = _read(process, "SNAPSHOT", label) + if not re.search(rf"\|pid={process.pid}\|", line): + raise LeakError("leak SNAPSHOT marker has the wrong PID") + _snapshot( + umdh, + process.pid, + snapshot_dir / f"{label}.txt", + label == "baseline", + environment, + ) + assert process.stdin is not None + process.stdin.write(f"continue|{label}\n") + process.stdin.flush() + _read(process, "DONE") + try: + result = process.wait(timeout=120) + except psutil.TimeoutExpired as error: + raise LeakError("leak probe did not exit after its final snapshot") from error + if result: + errors.flush(); errors.seek(0) + raise LeakError(f"leak probe failed ({result}): {errors.read()}") + finally: + if process is not None: + if process.poll() is None: + _kill_tree(process) + assert process.stdin is not None and process.stdout is not None + process.stdin.close() + process.stdout.close() + assert process is not None + _json("capture.json", {"mode": mode, "scenario": scenario, "processId": process.pid, "windows": windows}) + + +def _diff(args: Sequence[str]) -> None: + umdh_value, directory, label, before, after = _exact("diff", args, 5) + report = _output() / "report.txt" + environment = os.environ | {"_NT_SYMBOL_PATH": directory, "OANOCACHE": "1"} + result = subprocess.run([umdh_value, "-d", before, after, f"-f:{report}"], env=environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", errors="replace") + if result.returncode or not report.is_file(): + raise LeakError(f"UMDH comparison failed ({result.returncode}): {result.stdout}") + text = report.read_text(encoding="utf-8", errors="replace") + totals = list(re.finditer(r"^Total (increase|decrease)\s*==\s*([0-9]+)", text, re.M)) + if not totals: + raise LeakError("UMDH comparison contains no total allocation delta") + total = int(totals[-1].group(2)) * (-1 if totals[-1].group(1) == "decrease" else 1) + stacks = {match.group(2): int(match.group(1)) for match in re.finditer(r"^\+\s+([0-9]+)\s+\([^)]*\)\s+[0-9]+\s+allocs\s+BackTrace\s*([0-9A-Fa-f]+)", text, re.M)} + _json("diff.json", {"label": label, "totalIncrease": total, "positiveStacks": stacks, "report": "report.txt"}) + + +def _summary(args: Sequence[str]) -> dict[str, object]: + if len(args) < 8 or len(args) % 2: + raise LeakError("judge expects settings followed by label/report pairs") + mode, scenario, warmup_value, iterations_value, windows_value, tolerance_value, *pairs = args + _selection(mode, scenario) + warmup, iterations, windows = _count(warmup_value, "warmup"), _count(iterations_value, "iterations"), _count(windows_value, "windows", minimum=3) + tolerance = _count(tolerance_value, "tolerance", minimum=0) + labels, records = pairs[::2], [json.loads(Path(path).read_text(encoding="utf-8")) for path in pairs[1::2]] + expected = [*(f"window-{index}" for index in range(1, windows)), "overall"] + if labels != expected or [record.get("label") for record in records] != expected: + raise LeakError("judge comparison labels do not match the measurement windows") + previous, last, overall = records[-3], records[-2], records[-1] + repeated = sorted(key for key, value in last["positiveStacks"].items() if value > tolerance and previous["positiveStacks"].get(key, 0) > tolerance) + sustained = last["totalIncrease"] > tolerance and previous["totalIncrease"] > tolerance and overall["totalIncrease"] > 2 * tolerance + summary = {"mode": mode, "scenario": scenario, "warmupRounds": warmup, "iterationsPerWindow": iterations, "windows": windows, "toleranceBytes": tolerance, "totalGrowthByWindow": [record["totalIncrease"] for record in records[:-1]], "overallGrowthBytes": overall["totalIncrease"], "repeatedGrowingStacks": repeated, "passed": not sustained and not repeated} + _json("summary.json", summary) + return summary + + +def _summarize(args: Sequence[str]) -> None: + _summary(args) + + +def _judge(args: Sequence[str]) -> None: + summary = _summary(args) + if not summary["passed"]: + raise LeakError("UMDH found sustained heap growth") + + +def _gate(args: Sequence[str]) -> None: + (path,) = _exact("gate", args, 1) + try: + passed = json.loads(Path(path).read_text(encoding="utf-8"))["passed"] + except (OSError, json.JSONDecodeError, KeyError, TypeError) as error: + raise LeakError("leak summary is invalid") from error + if not isinstance(passed, bool): + raise LeakError("leak summary is invalid") + if not passed: + raise LeakError("UMDH found sustained heap growth") + + +_ACTIONS = { + "setup": _setup, "preflight": _preflight, "capture": _capture, + "diff": _diff, "summarize": _summarize, "gate": _gate, "judge": _judge, +} + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if not arguments or arguments[0] not in _ACTIONS: + raise LeakError("expected leak action: setup, preflight, capture, diff, or judge") + _ACTIONS[arguments[0]](arguments[1:]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/node.py b/build/core/node.py new file mode 100644 index 0000000..8798e47 --- /dev/null +++ b/build/core/node.py @@ -0,0 +1,68 @@ +"""Create signed graph nodes from rendered repository recipes.""" + +from dataclasses import dataclass +import json +from pathlib import Path + +from core.graph import Node, Result +from core.recipe import Recipe +from core.render import TemplateRenderer +from core.sign import content_uid + + +@dataclass(frozen=True, slots=True) +class NodeFactory: + renderer: TemplateRenderer + cwd: Path + identity: dict[str, str] + environment: tuple[tuple[str, str], ...] = () + + def make( + self, + template: str, + name: str, + pool: str, + variables: dict[str, object], + *, + files: dict[str, bytes], + dependencies: tuple[Node, ...] = (), + results: tuple[Result, ...] = (), + config: dict[str, str], + identity: dict[str, str] | None = None, + environment: tuple[tuple[str, str], ...] | None = None, + cwd: Path | None = None, + ) -> Node: + descriptor = { + "name": name, + "pool": pool, + "inputs": [node.name for node in dependencies], + } | variables + descriptor["results"] = [ + { + "id": result.id, + "kind": result.kind, + "media_type": result.media_type, + "path": result.relative_path, + } + for result in results + ] + rendered = self.renderer.render(template, descriptor) + node_environment = self.environment if environment is None else environment + node_cwd = cwd or self.cwd + runtime = json.dumps( + {"cwd": str(node_cwd), "environment": node_environment}, + ensure_ascii=False, + separators=(",", ":"), + ) + uid = content_uid( + recipe=rendered, + inputs=files, + dependencies={node.name: node.uid for node in dependencies}, + toolchain=identity or self.identity, + config=dict(config) | {"runtime": runtime}, + ) + return Recipe.parse(rendered).to_node( + uid=uid, + env=node_environment, + cwd=str(node_cwd), + ) diff --git a/build/core/package.py b/build/core/package.py new file mode 100644 index 0000000..ea71847 --- /dev/null +++ b/build/core/package.py @@ -0,0 +1,224 @@ +"""Deterministic staging and ZIP creation for release packages.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +from collections.abc import Sequence +import zipfile + + +ARCHITECTURES = ("x86", "x64", "arm64") +LICENSES = { + "renpy": ("Observer.txt", "rpatool.txt", "serde-pickle.txt", "zlib.txt"), + "rpgmaker": ("Observer.txt", "rgssad.txt"), + "zanzarah": ("Observer.txt", "zanzapak.txt"), +} +MODULES = tuple(LICENSES) +_ZIP_TIME = (1980, 1, 1, 0, 0, 0) + + +class PackageError(RuntimeError): + pass + + +def _output() -> Path: + value = os.environ.get("OBSERVER_OUT_DIR") + if not value or not (output := Path(value)).is_dir(): + raise PackageError("OBSERVER_OUT_DIR must be an existing directory") + return output + + +def _sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def _payload_files(payload: Path) -> dict[str, Path]: + return { + path.relative_to(payload).as_posix(): path + for path in sorted(payload.rglob("*")) + if path.is_file() + } + + +def _entries(files: dict[str, Path]) -> list[dict[str, str]]: + return [{"name": name, "sha256": _sha256(path)} for name, path in sorted(files.items())] + + +def _document(kind: str, architecture: str, module: str, payload: Path) -> dict[str, object]: + return { + "architecture": architecture, + "entries": _entries(_payload_files(payload)), + "kind": kind, + "module": module, + } + + +def _write_json(path: Path, value: object) -> None: + path.write_text( + json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _stage(args: argparse.Namespace, kind: str, files: dict[str, Path]) -> None: + output = _output() + payload = output / "payload" + for name, source in files.items(): + destination = payload / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + _write_json(output / "manifest.json", _document(kind, args.architecture, args.module, payload)) + + +def _stage_module(args: argparse.Namespace) -> None: + repository = args.repository + files = { + f"{args.module}.so": args.binary, + "observer_user.ini": repository / f"src/modules/{args.module}/observer_user.ini", + "docs/license.txt": repository / "LICENSE.txt", + } | {f"docs/thirdparty/{name}": repository / "licenses" / name for name in LICENSES[args.module]} + _stage(args, "module", files) + + +def _stage_symbol(args: argparse.Namespace) -> None: + _stage(args, "symbols", {f"{args.module}.pdb": args.symbol}) + + +def _stage_payload(stage: Path, kind: str, architecture: str, module: str) -> dict[str, Path]: + payload = stage / "payload" + expected = _document(kind, architecture, module, payload) + actual = json.loads((stage / "manifest.json").read_text(encoding="utf-8")) + if actual != expected: + raise PackageError(f"{kind} stage manifest does not match its payload") + return _payload_files(payload) + + +def _zip(destination: Path, files: dict[str, Path]) -> None: + with zipfile.ZipFile(destination, "w") as archive: + for name, path in sorted(files.items()): + information = zipfile.ZipInfo(name, _ZIP_TIME) + information.compress_type = zipfile.ZIP_DEFLATED + information.create_system = 3 + information.external_attr = 0o100644 << 16 + with path.open("rb") as source, archive.open(information, "w") as target: + shutil.copyfileobj(source, target, 1024 * 1024) + + +def _archive_module(args: argparse.Namespace) -> None: + files = _stage_payload(args.stage, "module", args.architecture, args.module) + _zip(_output() / f"{args.module}-{args.architecture}-dll.zip", files) + + +def _archive_symbols(args: argparse.Namespace) -> None: + _zip(_output() / f"observer-modules-{args.architecture}-pdb.zip", _symbol_payload(args)) + + +def _symbol_payload(args: argparse.Namespace) -> dict[str, Path]: + documents = [json.loads((stage / "manifest.json").read_text(encoding="utf-8")) for stage in args.stages] + modules = [document.get("module") for document in documents] + if set(modules) != set(MODULES) or len(modules) != len(MODULES): + raise PackageError("symbols archive requires the expected module set") + files = {} + for module, stage in sorted(zip(modules, args.stages, strict=True)): + files.update(_stage_payload(stage, "symbols", args.architecture, str(module))) + return files + + +def _archive_entries(path: Path) -> list[dict[str, str]]: + try: + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + if len(members) != len({member.filename for member in members}): + raise PackageError("package archive does not match exact stage manifests") + entries = [] + for member in sorted(members, key=lambda item: item.filename): + with archive.open(member) as stream: + entries.append({"name": member.filename, "sha256": hashlib.file_digest(stream, "sha256").hexdigest()}) + return entries + except (OSError, zipfile.BadZipFile, RuntimeError) as error: + raise PackageError("package archive does not match exact stage manifests") from error + + +def _validate(archive: Path, files: dict[str, Path]) -> None: + entries = _archive_entries(archive) + if entries != _entries(files): + raise PackageError("package archive does not match exact stage manifests") + _write_json(_output() / "validation.json", {"entries": entries, "name": archive.name, "sha256": _sha256(archive)}) + + +def _validate_module(args: argparse.Namespace) -> None: + _validate(args.archive, _stage_payload(args.stage, "module", args.architecture, args.module)) + + +def _validate_symbols(args: argparse.Namespace) -> None: + _validate(args.archive, _symbol_payload(args)) + + +def _aggregate(args: argparse.Namespace) -> None: + names = [archive.name for archive in args.archives] + if len(names) != len(set(names)): + raise PackageError("duplicate archive name in package manifest") + output = _output() + for archive in sorted(args.archives, key=lambda path: path.name): + shutil.copyfile(archive, output / archive.name) + _write_json( + output / "packages.json", + [ + {"name": archive.name, "sha256": _sha256(archive)} + for archive in sorted(args.archives, key=lambda path: path.name) + ], + ) + + +def _smoke(args: argparse.Namespace) -> None: + output = _output() + try: + with zipfile.ZipFile(args.archive) as archive: + module = Path(archive.extract(f"{args.module}.so", output)) + except KeyError as error: + raise PackageError(f"package archive has no {args.module}.so") from error + subprocess.run( + [str(args.tests), "[package-smoke]", "--reporter", "compact", "--rng-seed", "1"], + check=True, cwd=output, + env=os.environ | {"OBSERVER_PACKAGE_MODULE": str(module), "OBSERVER_PACKAGE_FORMAT": args.module}, + ) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(required=True) + actions = ( + ("stage-module", ("architecture", "module", "binary", "repository"), _stage_module), + ("stage-symbol", ("architecture", "module", "symbol"), _stage_symbol), + ("archive-module", ("architecture", "module", "stage"), _archive_module), + ("archive-symbols", ("architecture", "stages"), _archive_symbols), + ("validate-module", ("architecture", "module", "archive", "stage"), _validate_module), + ("validate-symbols", ("architecture", "archive", "stages"), _validate_symbols), + ("aggregate", ("archives",), _aggregate), + ("smoke", ("architecture", "module", "archive", "tests"), _smoke), + ) + for command, names, action in actions: + current = commands.add_parser(command) + for name in names: + choices = ARCHITECTURES if name == "architecture" else MODULES if name == "module" else None + current.add_argument( + name, + choices=choices, + nargs="+" if name in {"stages", "archives"} else None, + type=None if choices else Path, + ) + current.set_defaults(run=action) + args = parser.parse_args(argv) + args.run(args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/paths.py b/build/core/paths.py new file mode 100644 index 0000000..34d6bc4 --- /dev/null +++ b/build/core/paths.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import os +import re +import stat +from dataclasses import dataclass +from pathlib import Path + +_UID_PATTERN = re.compile(r"[0-9a-f]{32}\Z") +_RUN_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") + + +class PathSafetyError(ValueError): + """A build path did not satisfy the output confinement policy.""" + + +@dataclass(frozen=True) +class CasPaths: + entry: Path + output: Path + touch: Path + log: Path + + +def _is_reparse(path: Path) -> bool: + try: + information = os.lstat(path) + except FileNotFoundError: + return False + attributes = getattr(information, "st_file_attributes", 0) + reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return stat.S_ISLNK(information.st_mode) or bool(attributes & reparse_attribute) + + +def _absolute(path: Path) -> Path: + return Path(os.path.abspath(os.fspath(path))) + + +def _is_within(path: Path, root: Path) -> bool: + return path == root or root in path.parents + + +class BuildPaths: + """Derive and validate the repository-local ``out/{cas,work}`` layout.""" + + def __init__(self, repository: Path | str) -> None: + repository_path = _absolute(Path(repository)) + if not repository_path.is_dir() or _is_reparse(repository_path): + raise PathSafetyError("repository must be an existing directory, not a reparse point") + + self.repository = repository_path + self.output_root = repository_path / "out" + self.cas_root = self.output_root / "cas" + self.work_root = self.output_root / "work" + self.locks_root = self.work_root / ".locks" + + def prepare(self) -> None: + for path, root in ( + (self.output_root, self.output_root), + (self.cas_root, self.cas_root), + (self.work_root, self.work_root), + (self.locks_root, self.work_root), + ): + self.require_confined(path, root).mkdir(exist_ok=True) + + def cas(self, uid: str) -> CasPaths: + self._require_uid(uid) + entry = self.require_confined(self.cas_root / uid, self.cas_root) + paths = CasPaths( + entry=entry, + output=entry / "out", + touch=entry / "touch", + log=entry / "log.txt", + ) + for path in (paths.output, paths.touch, paths.log): + self._reject_existing_reparse_points(path) + return paths + + def run_work(self, run_identifier: str) -> Path: + if _RUN_PATTERN.fullmatch(run_identifier) is None: + raise PathSafetyError(f"invalid run identifier: {run_identifier!r}") + return self.require_confined(self.work_root / run_identifier, self.work_root) + + def lock(self, uid: str) -> Path: + self._require_uid(uid) + return self.require_confined(self.locks_root / f"{uid}.lock", self.work_root) + + def coordination_lock(self) -> Path: + return self.require_confined(self.locks_root / "coordination.lock", self.work_root) + + def lease(self, run_identifier: str) -> Path: + self.run_work(run_identifier) + return self.require_confined( + self.locks_root / f"run-{run_identifier}.lease", self.work_root + ) + + def require_confined(self, candidate: Path | str, allowed_root: Path | str) -> Path: + path = _absolute(Path(candidate)) + root = _absolute(Path(allowed_root)) + if root not in (self.output_root, self.cas_root, self.work_root): + raise PathSafetyError(f"unexpected allowed root: {root}") + if not _is_within(path, root): + raise PathSafetyError(f"path is outside allowed root: {path}") + self._reject_existing_reparse_points(path) + return path + + def _reject_existing_reparse_points(self, path: Path) -> None: + if not _is_within(path, self.repository): + raise PathSafetyError(f"path is outside repository: {path}") + current = path + while True: + if _is_reparse(current): + raise PathSafetyError(f"reparse point is forbidden in build path: {current}") + if current == self.repository: + return + current = current.parent + + @staticmethod + def _require_uid(uid: str) -> None: + if _UID_PATTERN.fullmatch(uid) is None: + raise PathSafetyError(f"UID must be 32 lowercase hexadecimal characters: {uid!r}") diff --git a/build/core/python_coverage.py b/build/core/python_coverage.py new file mode 100644 index 0000000..841ab01 --- /dev/null +++ b/build/core/python_coverage.py @@ -0,0 +1,56 @@ +"""Run the project-local coverage.py gate and publish its native evidence.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +from collections.abc import Sequence + + +def _directory(name: str) -> Path: + value = os.environ.get(name) + if not value or not (path := Path(value)).is_dir(): + raise RuntimeError(f"{name} must name an existing directory") + return path.resolve() + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("coverage", type=Path) + parser.add_argument("build_root", type=Path) + args = parser.parse_args(argv) + coverage = args.coverage.resolve(strict=True) + build_root = args.build_root.resolve(strict=True) + config = build_root / "pyproject.toml" + if not coverage.is_file() or not build_root.is_dir() or not config.is_file(): + raise FileNotFoundError("coverage executable, build root, and config must exist") + + work, output = _directory("OBSERVER_BUILD_DIR"), _directory("OBSERVER_OUT_DIR") + data = work / ".coverage" + command = [str(coverage)] + subprocess.run(command + ["run", "--rcfile", str(config), "--data-file", str(data), + "-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"], + cwd=build_root, check=True) + report = subprocess.run( + command + ["report", "--rcfile", str(config), "--data-file", str(data), "--fail-under=100"], + cwd=build_root, check=False, stdout=subprocess.PIPE, text=True, + ) + (output / "coverage.txt").write_text(report.stdout, encoding="utf-8") + subprocess.run(command + ["json", "--rcfile", str(config), "--data-file", str(data), + "--fail-under=0", "-o", str(output / "coverage.json")], + cwd=build_root, check=True) + subprocess.run(command + ["xml", "--rcfile", str(config), "--data-file", str(data), + "--fail-under=0", "-o", str(output / "coverage.xml")], + cwd=build_root, check=True) + shutil.copyfile(config, output / "coverage.toml") + shutil.copyfile(data, output / data.name) + print(report.stdout, end="") + report.check_returncode() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/quality_tools.py b/build/core/quality_tools.py new file mode 100644 index 0000000..5ecff94 --- /dev/null +++ b/build/core/quality_tools.py @@ -0,0 +1,175 @@ +"""Resolve immutable identities for optional local quality tools without installing them.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import os +from pathlib import Path +import shutil + +from core.toolchain import MsvcToolchain + + +@dataclass(frozen=True, slots=True) +class ResolvedTool: + path: Path + identity: tuple[tuple[str, str], ...] + + +@dataclass(frozen=True, slots=True) +class ResolvedDirectory: + path: Path + files: tuple[ResolvedTool, ...] + + @property + def identity(self) -> tuple[tuple[str, str], ...]: + return (("path", str(self.path)),) + tuple( + (f"{tool.path.name}.{key}", value) + for tool in self.files + for key, value in tool.identity + ) + + +@dataclass(frozen=True, slots=True) +class QualityTools: + clang_cl: ResolvedTool + clang_scan_deps: ResolvedTool + llvm_cov: ResolvedTool + llvm_profdata: ResolvedTool + dumpbin: ResolvedTool + binskim: ResolvedTool + umdh: ResolvedTool + + +@dataclass(frozen=True, slots=True) +class SanitizerRuntimes: + asan_x86: ResolvedTool + asan_x64: ResolvedTool + ubsan: ResolvedDirectory + + +_LLVM_TOOLS = frozenset(("clang-cl", "clang-scan-deps", "llvm-cov", "llvm-profdata")) +UBSAN_LIBRARIES = ( + "clang_rt.ubsan_standalone-x86_64.lib", + "clang_rt.ubsan_standalone_cxx-x86_64.lib", +) +_ASAN_RUNTIMES = { + "x86": "clang_rt.asan_dynamic-i386.dll", + "x64": "clang_rt.asan_dynamic-x86_64.dll", +} + + +def resolve_tool(path: Path | str | None, name: str) -> ResolvedTool: + try: + resolved = Path(path).resolve(strict=True) if path else None + except (OSError, RuntimeError) as error: + raise FileNotFoundError(f"missing {name}: {path}") from error + if resolved is None or not resolved.is_file(): + raise FileNotFoundError(f"missing {name}: {path}") + with resolved.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + return ResolvedTool(resolved, (("path", str(resolved)), ("sha256", digest))) + + +def _identity_value(toolchain: MsvcToolchain, key: str, name: str) -> str: + value = dict(toolchain.identity).get(key) + if not value: + raise FileNotFoundError(f"missing {name} identity") + return value + + +def resolve_llvm(toolchain: MsvcToolchain, name: str) -> ResolvedTool: + if name not in _LLVM_TOOLS: + raise ValueError(f"unsupported LLVM tool: {name}") + return resolve_tool(toolchain.llvm_dir / f"bin/{name}.exe", name) + + +def resolve_dumpbin(toolchain: MsvcToolchain) -> ResolvedTool: + version = _identity_value(toolchain, "vc_tools_version", "MSVC vc_tools_version") + path = toolchain.installation / f"VC/Tools/MSVC/{version}/bin/Hostx64/x64/dumpbin.exe" + return resolve_tool(path, "dumpbin") + + +def resolve_binskim() -> ResolvedTool: + return resolve_tool(shutil.which("binskim"), "BinSkim") + + +def resolve_umdh() -> ResolvedTool: + override = os.environ.get("OBSERVER_UMDH") + if override: + path = Path(override) + if not path.is_file(): + raise FileNotFoundError(f"missing UMDH override: {path}") + return resolve_tool(path, "UMDH") + program_files = os.environ.get("ProgramFiles(x86)") + candidates = (() if not program_files else tuple( + Path(program_files) / f"Windows Kits/{version}/Debuggers/x64/umdh.exe" + for version in ("11", "10") + )) + for candidate in candidates: + if candidate.is_file(): + return resolve_tool(candidate, "UMDH") + searched = ", ".join(map(str, candidates)) or "ProgramFiles(x86) is unset" + raise FileNotFoundError(f"missing UMDH: {searched}") + + +def resolve_asan_runtimes( + toolchain: MsvcToolchain, architectures: tuple[str, ...] +) -> tuple[tuple[str, ResolvedTool], ...]: + """Resolve only the requested MSVC ASan runtime DLLs.""" + + unsupported = next( + (architecture for architecture in architectures if architecture not in _ASAN_RUNTIMES), + None, + ) + if unsupported is not None: + raise ValueError(f"unsupported ASan architecture: {unsupported}") + msvc = _identity_value(toolchain, "vc_tools_version", "MSVC vc_tools_version") + asan = toolchain.installation / f"VC/Tools/MSVC/{msvc}/bin/Hostx64" + return tuple( + ( + architecture, + resolve_tool( + asan / architecture / _ASAN_RUNTIMES[architecture], + f"MSVC ASan {architecture} runtime", + ), + ) + for architecture in architectures + ) + + +def resolve_ubsan_runtime(toolchain: MsvcToolchain) -> ResolvedDirectory: + """Resolve only the LLVM x64 UBSan archive pair.""" + + llvm = _identity_value(toolchain, "clang_tidy_version", "LLVM clang_tidy_version") + root = toolchain.llvm_dir / "lib/clang" + candidates = tuple(root / version / "lib/windows" for version in dict.fromkeys((llvm, llvm.split(".")[0]))) + directory = next((candidate.resolve() for candidate in candidates if candidate.is_dir()), None) + if directory is None: + raise FileNotFoundError(f"missing UBSan runtime directory: {', '.join(map(str, candidates))}") + files = tuple(resolve_tool(directory / name, f"UBSan runtime {name}") for name in UBSAN_LIBRARIES) + return ResolvedDirectory(directory, files) + + +def resolve_sanitizer_runtimes(toolchain: MsvcToolchain) -> SanitizerRuntimes: + """Resolve every sanitizer runtime required by doctor and aggregate verification.""" + + asan = dict(resolve_asan_runtimes(toolchain, ("x86", "x64"))) + return SanitizerRuntimes( + asan["x86"], asan["x64"], resolve_ubsan_runtime(toolchain) + ) + + +def discover_quality_tools(toolchain: MsvcToolchain) -> QualityTools: + """Resolve the exact quality-tool files expected by coverage, sanitizer, audit, and leak graphs.""" + + return QualityTools( + clang_cl=resolve_llvm(toolchain, "clang-cl"), + clang_scan_deps=resolve_llvm(toolchain, "clang-scan-deps"), + llvm_cov=resolve_llvm(toolchain, "llvm-cov"), + llvm_profdata=resolve_llvm(toolchain, "llvm-profdata"), + dumpbin=resolve_dumpbin(toolchain), + binskim=resolve_binskim(), + umdh=resolve_umdh(), + ) diff --git a/build/core/recipe.py b/build/core/recipe.py new file mode 100644 index 0000000..0c9cd23 --- /dev/null +++ b/build/core/recipe.py @@ -0,0 +1,76 @@ +"""Bridge trusted repository-rendered JSON recipes to graph nodes.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +import json + +from core.graph import Command, GraphError, Node, Result + + +class RecipeError(ValueError): + """A required repository recipe field is missing or malformed.""" + + +@dataclass(frozen=True, slots=True) +class Recipe: + name: str + pool: str + inputs: tuple[str, ...] + argv: tuple[str, ...] + data: bytes + results: tuple[Result, ...] = () + + @classmethod + def parse(cls, rendered: str | bytes) -> Recipe: + """Read only the fields emitted by the repository templates.""" + + try: + document = json.loads(rendered) + script = document["script"] + declared_results = document["results"] + if not isinstance(declared_results, list): + raise TypeError("results must be a list") + return cls( + name=document["name"], + pool=document["pool"], + inputs=tuple(document["inputs"]), + argv=tuple(script["exec"]), + data=script["data"].encode("utf-8"), + results=tuple( + Result( + result["id"], + result["kind"], + result["media_type"], + result["path"], + ) + for result in declared_results + ), + ) + except ( + json.JSONDecodeError, + UnicodeDecodeError, + KeyError, + TypeError, + AttributeError, + GraphError, + ) as error: + raise RecipeError("rendered recipe is missing required JSON fields") from error + + def to_node( + self, + *, + uid: str, + env: Mapping[str, str] | Iterable[tuple[str, str]] = (), + cwd: str | None = None, + ) -> Node: + environment = env.items() if isinstance(env, Mapping) else env + return Node( + name=self.name, + uid=uid, + pool=self.pool, + command=Command(self.argv, tuple(environment), cwd, self.data), + inputs=self.inputs, + results=self.results, + ) diff --git a/build/core/render.py b/build/core/render.py new file mode 100644 index 0000000..aa1bfdf --- /dev/null +++ b/build/core/render.py @@ -0,0 +1,55 @@ +"""Deterministic rendering for inherited build recipes.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import jinja2 + + +def ps_quote(value: object) -> str: + """Return *value* as one PowerShell single-quoted string literal.""" + + text = str(value) + return "'" + text.replace("'", "''") + "'" + + +def _json(value: object) -> str: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + + +class TemplateRenderer: + """Render flat, inherited Jinja recipes with undefined values forbidden.""" + + def __init__(self, template_dir: Path) -> None: + root = template_dir.resolve(strict=True) + if not root.is_dir(): + raise NotADirectoryError(root) + + self._environment = jinja2.Environment( + loader=jinja2.FileSystemLoader(root), + undefined=jinja2.StrictUndefined, + autoescape=False, + auto_reload=False, + trim_blocks=True, + lstrip_blocks=True, + keep_trailing_newline=True, + newline_sequence="\n", + ) + self._environment.filters["json"] = _json + self._environment.filters["ps_quote"] = ps_quote + + def render(self, template_name: str, variables: Mapping[str, Any]) -> str: + """Render *template_name* using an explicit variable mapping.""" + + template = self._environment.get_template(template_name) + return template.render(dict(variables)) diff --git a/build/core/result_export.py b/build/core/result_export.py new file mode 100644 index 0000000..0218e66 --- /dev/null +++ b/build/core/result_export.py @@ -0,0 +1,290 @@ +"""Atomically publish declared graph results without exposing CAS paths.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import tempfile +from typing import Any, Iterable, Mapping + +from core.graph import Graph, Node, Result +from core.store import CasStore + + +_COMMAND = re.compile(r"[a-z0-9][a-z0-9._-]*") +_STATUSES = {"success", "failed"} +_PACKAGE_PREFIX = "packages/" +_RESERVED = ("manifest.json", "logs", "packages") +_PACKAGE_RESERVED = ("manifest.json",) + + +class ResultExportError(RuntimeError): + """Declared results could not be published safely and completely.""" + + +def _lstat(path: Path) -> os.stat_result | None: + try: + return os.lstat(path) + except FileNotFoundError: + return None + + +def _is_reparse(path: Path) -> bool: + information = os.lstat(path) + attributes = getattr(information, "st_file_attributes", 0) + reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return stat.S_ISLNK(information.st_mode) or bool(attributes & reparse_attribute) + + +def _checked(path: Path) -> os.stat_result: + information = _lstat(path) + if information is None: + raise ResultExportError(f"declared result is missing: {path.name}") + if _is_reparse(path): + raise ResultExportError(f"reparse points are forbidden in exported results: {path.name}") + return information + + +def _source(output: Path, result: Result) -> tuple[Path, os.stat_result]: + parts = result.relative_path.split("/") + current = output / parts[0] + information = _checked(current) + for part in parts[1:]: + if not stat.S_ISDIR(information.st_mode): + raise ResultExportError(f"declared result path is not a directory: {current.name}") + current /= part + information = _checked(current) + return current, information + + +def _digest_file(path: Path) -> tuple[int, str]: + size = path.stat().st_size + with path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + return size, digest + + +def _copy_file(source: Path, destination: Path) -> tuple[int, str]: + information = _checked(source) + if not stat.S_ISREG(information.st_mode): + raise ResultExportError(f"result is not a regular file: {source.name}") + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination, follow_symlinks=False) + return _digest_file(destination) + + +def _tree_record( + digest: Any, marker: bytes, relative: Path, size: int, content_digest: str = "" +) -> None: + encoded = relative.as_posix().encode("utf-8") + digest.update(marker) + digest.update(len(encoded).to_bytes(8, "big")) + digest.update(encoded) + digest.update(size.to_bytes(8, "big")) + digest.update(bytes.fromhex(content_digest)) + + +def _copy_directory(source: Path, destination: Path) -> tuple[int, str]: + destination.mkdir(parents=True) + total = 0 + digest = hashlib.sha256() + pending = [(source, Path())] + while pending: + current, relative_root = pending.pop() + directories: list[tuple[Path, Path]] = [] + with os.scandir(current) as entries: + for entry in sorted(entries, key=lambda item: item.name): + source_entry = Path(entry.path) + relative = relative_root / entry.name + information = _checked(source_entry) + if stat.S_ISDIR(information.st_mode): + (destination / relative).mkdir() + _tree_record(digest, b"D", relative, 0) + directories.append((source_entry, relative)) + elif stat.S_ISREG(information.st_mode): + size, file_digest = _copy_file(source_entry, destination / relative) + total += size + _tree_record(digest, b"F", relative, size, file_digest) + else: + raise ResultExportError(f"unsupported result entry: {entry.name}") + pending.extend(reversed(directories)) + return total, digest.hexdigest() + + +def _copy_result(source: Path, information: os.stat_result, destination: Path) -> tuple[str, int, str]: + if stat.S_ISREG(information.st_mode): + size, digest = _copy_file(source, destination) + return "file", size, digest + if stat.S_ISDIR(information.st_mode): + size, digest = _copy_directory(source, destination) + return "directory", size, digest + raise ResultExportError(f"result must be a file or directory: {source.name}") + + +def _overlap(first: str, second: str) -> bool: + return first == second or first.startswith(second + "/") or second.startswith(first + "/") + + +def _validate_result_paths(graph: Graph) -> None: + occupied = list(_RESERVED) + package_occupied = list(_PACKAGE_RESERVED) + for current in graph.nodes: + for result in current.results: + if result.id.startswith(_PACKAGE_PREFIX): + relative = result.id.removeprefix(_PACKAGE_PREFIX) + paths = package_occupied + else: + relative = result.id + paths = occupied + if any(_overlap(relative, path) for path in paths): + raise ResultExportError(f"colliding export path: {result.id}") + paths.append(relative) + + +def _validate_manifest_inputs( + graph: Graph, command: str, status: str, failures: Iterable[str] +) -> tuple[str, ...]: + if not isinstance(command, str) or _COMMAND.fullmatch(command) is None: + raise ResultExportError("command must be a lowercase logical name") + if status not in _STATUSES: + raise ResultExportError(f"invalid export status: {status!r}") + failure_names = tuple(failures) + known = {current.name for current in graph.nodes} + if len(failure_names) != len(set(failure_names)) or any( + name not in known for name in failure_names + ): + raise ResultExportError("failures must contain unique graph node names") + if status != "failed" and failure_names: + raise ResultExportError("successful export cannot contain failures") + return failure_names + + +def _validate_destination(destination: Path) -> None: + if _lstat(destination) is not None: + raise ResultExportError(f"export destination already exists: {destination}") + information = _lstat(destination.parent) + if information is None or not stat.S_ISDIR(information.st_mode): + raise ResultExportError("export destination parent must be an existing directory") + current = destination.parent + while True: + if _is_reparse(current): + raise ResultExportError(f"reparse point in export destination: {current}") + if current.parent == current: + return + current = current.parent + + +def _write_manifest(staging: Path, document: dict[str, object]) -> None: + with (staging / "manifest.json").open("x", encoding="utf-8", newline="\n") as stream: + json.dump(document, stream, ensure_ascii=False, indent=2, sort_keys=True) + stream.write("\n") + + +def _result_entry( + node: Node, result: Result, staging: Path, store: CasStore, relative: str +) -> dict[str, object]: + source, information = _source(store.paths_for(node).output, result) + object_type, size, digest = _copy_result(source, information, staging / relative) + return { + "id": result.id, + "kind": result.kind, + "media_type": result.media_type, + "object_type": object_type, + "path": relative, + "producer_uid": node.uid, + "sha256": digest, + "size": size, + } + + +def export_results( + graph: Graph, + store: CasStore, + destination: Path | str, + command: str, + status: str, + *, + failures: Iterable[str] = (), + cache: Mapping[str, object] | None = None, +) -> Path: + """Publish complete declared results and a relative-path-only manifest.""" + + failure_names = _validate_manifest_inputs(graph, command, status, failures) + _validate_result_paths(graph) + published = Path(os.path.abspath(os.fspath(destination))) + _validate_destination(published) + try: + with tempfile.TemporaryDirectory( + prefix=f".{published.name}.tmp-", dir=published.parent + ) as temporary: + staging = Path(temporary) + complete_results = [ + (current, result) + for current in graph.nodes + if store.is_complete(current) + for result in current.results + ] + results = [ + _result_entry(current, result, staging, store, result.id) + for current, result in complete_results + if not result.id.startswith(_PACKAGE_PREFIX) + ] + package_results: list[dict[str, object]] = [] + if status == "success": + package_root = staging / "packages" + package_results = [ + _result_entry( + current, + result, + package_root, + store, + result.id.removeprefix(_PACKAGE_PREFIX), + ) + for current, result in complete_results + if result.id.startswith(_PACKAGE_PREFIX) + ] + if package_results: + _write_manifest( + package_root, + { + "schema": 1, + "command": command, + "status": "success", + "failures": [], + "results": package_results, + "logs": [], + }, + ) + logs: list[dict[str, object]] = [] + if status == "failed": + for current in graph.nodes: + source = store.paths_for(current).log + if _lstat(source) is None: + continue + relative = f"logs/{current.name}.log" + size, digest = _copy_file(source, staging / relative) + logs.append( + {"node": current.name, "path": relative, "sha256": digest, "size": size} + ) + document: dict[str, object] = { + "schema": 1, + "command": command, + "status": status, + "failures": list(failure_names), + "results": results, + "logs": logs, + } + if cache is not None: + document["cache"] = dict(cache) + _write_manifest(staging, document) + if _lstat(published) is not None: + raise ResultExportError(f"export destination already exists: {published}") + staging.rename(published) + except OSError as error: + raise ResultExportError(f"could not publish result export: {error}") from error + return published diff --git a/build/core/runtime.py b/build/core/runtime.py new file mode 100644 index 0000000..929e6f8 --- /dev/null +++ b/build/core/runtime.py @@ -0,0 +1,188 @@ +"""Minimal bridge from graph execution to the repository-local Windows CAS.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress +from pathlib import Path +import shutil +import time + +from filelock import AsyncFileLock + +from core.execute import Executor +from core.graph import Command, Graph, Node +from core.paths import BuildPaths +from core.store import CasStore +from core.windows_process import WindowsProcessRunner + + +class ProcessFailed(RuntimeError): + """A build node process returned a nonzero exit code.""" + + +class _LeasedExecutor: + def __init__(self, runtime: BuildRuntime, executor: Executor) -> None: + self._runtime = runtime + self._executor = executor + + async def run(self) -> None: + if self._runtime.owned_run_id is not None: + await self._executor.run() + return + async with self._runtime.session(): + await self._executor.run() + + +class BuildRuntime: + """Wire the generic executor to locks, CAS paths, and process execution.""" + + def __init__( + self, + repository: Path | str, + run_id: str, + process_runner: WindowsProcessRunner = WindowsProcessRunner(), + ) -> None: + self.paths = BuildPaths(repository) + self.store = CasStore(self.paths, run_id) + self._run_id = run_id + self._runner = process_runner + self._observed: dict[str, Node] = {} + self._durations_ms: dict[str, int] = {} + self._session_lease: AsyncFileLock | None = None + + @property + def owned_run_id(self) -> str | None: + return self._run_id if self._session_lease is not None else None + + @asynccontextmanager + async def session(self) -> AsyncIterator[None]: + """Hold this runtime's run lease across a complete public operation.""" + + if self._session_lease is not None: + raise RuntimeError("build runtime session is already active") + coordination = self._lock(self.paths.coordination_lock()) + lease = self._lock(self.paths.lease(self._run_id)) + async with coordination: + self.paths.prepare() + await lease.acquire() + self._session_lease = lease + try: + yield + finally: + self._session_lease = None + await lease.release() + + def is_complete(self, current: Node) -> bool: + complete = self.store.is_complete(current) + self._observed.setdefault(current.uid, current) + return complete + + def _known_nodes(self, graph: Graph | None) -> dict[str, Node]: + known = dict(self._observed) + if graph is not None: + for current in graph.nodes: + known.setdefault(current.uid, current) + return known + + def live_uids(self, graph: Graph | None = None) -> tuple[str, ...]: + """Return known graph nodes that have a published completion marker.""" + + return tuple(sorted( + uid for uid, current in self._known_nodes(graph).items() + if self.store.is_complete(current) + )) + + def cache_report(self, graph: Graph | None = None) -> dict[str, object]: + """Describe deterministic node identities and their result in this process.""" + + nodes: list[dict[str, object]] = [] + summary = {"executed": 0, "failed": 0, "hit": 0, "incomplete": 0} + for uid, current in sorted( + self._known_nodes(graph).items(), key=lambda item: (item[1].name, item[0]) + ): + complete = self.store.is_complete(current) + if uid in self._durations_ms: + state = "executed" if complete else "failed" + elif complete: + state = "hit" + else: + state = "incomplete" + summary[state] += 1 + nodes.append({ + "duration_ms": self._durations_ms.get(uid, 0), + "name": current.name, + "state": state, + "uid": uid, + }) + return {"schema": 1, "summary": summary, "nodes": nodes} + + @staticmethod + def _lock(path: Path) -> AsyncFileLock: + return AsyncFileLock( + path, + timeout=-1, + poll_interval=0.05, + fallback_to_soft=False, + preserve_lock_file=True, + ) + + def lock(self, current: Node) -> AsyncFileLock: + return self._lock(self.paths.lock(current.uid)) + + async def run(self, current: Node) -> None: + self._observed.setdefault(current.uid, current) + started = time.perf_counter() + try: + await self._run_node(current) + finally: + self._durations_ms[current.uid] = max( + 0, round((time.perf_counter() - started) * 1000) + ) + + async def _run_node(self, current: Node) -> None: + reserved = ("OBSERVER_OUT_DIR", "OBSERVER_BUILD_DIR", "_MSPDBSRV_ENDPOINT_") + existing = {key.casefold() for key, _value in current.command.env} + for key in reserved: + if key.casefold() in existing: + raise ValueError(f"command environment conflicts with {key}") + + cas = self.store.prepare_entry(current) + run_root = self.paths.run_work(self._run_id) + work = self.paths.require_confined(run_root / current.uid, self.paths.work_root) + work.mkdir(exist_ok=False) + command = Command( + current.command.argv, + env=current.command.env + + ( + ("OBSERVER_OUT_DIR", str(cas.output)), + ("OBSERVER_BUILD_DIR", str(work)), + ("_MSPDBSRV_ENDPOINT_", f"observer_{current.uid}"), + ), + cwd=current.command.cwd, + stdin=current.command.stdin, + ) + with cas.log.open("r+b") as log: + exit_code = await self._runner.run(command, log=log) + if exit_code != 0: + raise ProcessFailed(f"process exited {exit_code} for node {current.name}") + scratch = self.paths.require_confined(work, self.paths.work_root) + await asyncio.to_thread(shutil.rmtree, scratch) + with suppress(OSError): + run_root.rmdir() + + def publish(self, current: Node) -> None: + self.store.mark_complete(current) + + def executor(self, graph: Graph) -> _LeasedExecutor: + return _LeasedExecutor( + self, + Executor( + graph, + is_complete=self.is_complete, + acquire_lock=self.lock, + runner=self.run, + publish=self.publish, + ), + ) diff --git a/build/core/sanitizer.py b/build/core/sanitizer.py new file mode 100644 index 0000000..f93f935 --- /dev/null +++ b/build/core/sanitizer.py @@ -0,0 +1,50 @@ +"""Fail-closed semantic gates for native sanitizer logs.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path +import re + + +class SanitizerError(RuntimeError): + pass + + +_FINDINGS = { + "asan": re.compile( + r"(?:ERROR|SUMMARY):\s*AddressSanitizer|AddressSanitizer:DEADLYSIGNAL", + re.IGNORECASE, + ), + "ubsan": re.compile( + r"runtime error:|UndefinedBehaviorSanitizer(?::DEADLYSIGNAL|: undefined-behavior)", + re.IGNORECASE, + ), +} + + +def require_clean_log(sanitizer: str, content: str) -> None: + try: + pattern = _FINDINGS[sanitizer] + except KeyError as error: + raise SanitizerError(f"unsupported sanitizer: {sanitizer}") from error + if pattern.search(content): + raise SanitizerError(f"{sanitizer} finding in test log") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + gate = commands.add_parser("gate") + gate.add_argument("sanitizer", choices=tuple(_FINDINGS)) + gate.add_argument("log") + args = parser.parse_args(argv) + require_clean_log( + args.sanitizer, Path(args.log).read_text(encoding="utf-8-sig") + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/sarif.py b/build/core/sarif.py new file mode 100644 index 0000000..23533df --- /dev/null +++ b/build/core/sarif.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +from collections.abc import Iterable, Sequence +from pathlib import Path +from typing import Any + + +_DIAGNOSTIC = re.compile( + r"^(.+)\((\d+),(\d+)\):\s+(warning|error)\s*:\s*(.*?)\s+\[([^\]]+)\]" + r"(?:\s+\[[^\]]+\.vcxproj\])?\s*$" +) + + +class SarifError(ValueError): + pass + + +class SarifFindingsError(SarifError): + pass + + +def _read(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]: + try: + document = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError) as error: + raise SarifError(f"cannot read SARIF report {path}: {error}") from error + if not isinstance(document, dict) or document.get("version") != "2.1.0": + raise SarifError(f"SARIF report is not version 2.1.0: {path}") + runs = document.get("runs") + if not isinstance(runs, list) or not runs or any(not isinstance(run, dict) for run in runs): + raise SarifError(f"SARIF runs must be a non-empty list of objects: {path}") + return document, runs + + +def _write(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _document(runs: list[dict[str, Any]]) -> dict[str, Any]: + return {"$schema": "https://json.schemastore.org/sarif-2.1.0.json", "runs": runs, "version": "2.1.0"} + + +def _tidy_result(finding: tuple[str, int, int, str, str, str]) -> dict[str, Any]: + path, line, column, level, rule, message = finding + location = { + "artifactLocation": {"uri": path}, + "region": {"startColumn": column, "startLine": line}, + } + return { + "level": level, + "locations": [{"physicalLocation": location}], + "message": {"text": message}, + "ruleId": rule, + } + + +def _tidy_rule(rule: str) -> dict[str, Any]: + return {"id": rule, "name": rule, "shortDescription": {"text": f"clang-tidy check {rule}"}} + + +def clang_tidy_to_sarif( + repository: Path, log_root: Path, output: Path, automation_id: str +) -> None: + repository = repository.resolve() + findings: set[tuple[str, int, int, str, str, str]] = set() + for log in sorted(log_root.rglob("*.ClangTidy.log")) if log_root.is_dir() else (): + for line in log.read_text(encoding="utf-8-sig", errors="replace").splitlines(): + match = _DIAGNOSTIC.match(line) + if not match: + continue + rule = next( + (check for raw in match[6].split(",") if (check := raw.strip()) and not check.startswith("-")), + None, + ) + if not rule: + continue + try: + relative = Path(match[1]).resolve().relative_to(repository).as_posix() + except (OSError, ValueError): + continue + line_number, column, level = int(match[2]), int(match[3]), match[4] + findings.add((relative, line_number, column, level, rule, match[5].strip())) + + ordered = sorted(findings, key=lambda item: (item[0], item[1], item[2], item[4], item[5], item[3])) + driver = { + "informationUri": "https://clang.llvm.org/extra/clang-tidy/", + "name": "clang-tidy", + "rules": [_tidy_rule(rule) for rule in sorted({finding[4] for finding in findings})], + } + run = { + "automationDetails": {"id": automation_id}, + "results": [_tidy_result(finding) for finding in ordered], + "tool": {"driver": driver}, + } + _write(output, _document([run])) + + +def normalize_msvc(source: Path, output: Path, automation_id: str) -> None: + document, runs = _read(source) + for index, run in enumerate(runs, 1): + details = run.get("automationDetails") + if not isinstance(details, dict): + details = run["automationDetails"] = {} + details["id"] = automation_id if len(runs) == 1 else f"{automation_id.rstrip('/')}/run-{index}/" + _write(output, document) + + +def merge_sarif(inputs: Iterable[Path], output: Path) -> None: + identified: list[tuple[str, dict[str, Any]]] = [] + for path in inputs: + _, runs = _read(path) + for run in runs: + details = run.get("automationDetails") + identity = details.get("id") if isinstance(details, dict) else None + if not isinstance(identity, str) or not identity: + raise SarifError(f"SARIF run has no automationDetails.id: {path}") + identified.append((identity, run)) + if not identified: + raise SarifError("SARIF merge requires at least one input") + identities = [identity for identity, _ in identified] + if len(set(identities)) != len(identities): + raise SarifError("SARIF merge found duplicate automationDetails.id values") + _write(output, _document([run for _, run in sorted(identified)])) + + +def require_clean(inputs: Iterable[Path]) -> None: + count = 0 + for path in inputs: + _, runs = _read(path) + for run in runs: + results = run.get("results", []) + if not isinstance(results, list) or any(not isinstance(result, dict) for result in results): + raise SarifError(f"SARIF results must be a list of objects: {path}") + count += sum(result.get("level", "warning") in {"warning", "error"} for result in results) + if count: + raise SarifFindingsError(f"analysis found {count} warning/error finding(s)") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + + def add_command(name: str, *arguments: str, output: str | None = None) -> None: + command = commands.add_parser(name) + for argument in arguments: + command.add_argument(argument, **({"nargs": "+"} if argument == "inputs" else {})) + if output: + command.add_argument("--output-name", default=output) + + add_command("normalize-msvc", "input", "automation_id", output="renpy.sarif") + add_command("convert-tidy", "repository", "log_root", "automation_id", output="renpy.sarif") + add_command("merge", "inputs", output="analysis.sarif") + add_command("gate", "input") + args = parser.parse_args(argv) + + raw_root = os.environ.get("OBSERVER_OUT_DIR") + if not raw_root: + parser.error("OBSERVER_OUT_DIR is required") + root = Path(raw_root).resolve() + if not root.is_dir(): + parser.error("OBSERVER_OUT_DIR must be an existing directory") + if args.command == "gate": + require_clean((Path(args.input),)) + return 0 + output = (root / args.output_name).resolve() + if output == root or not output.is_relative_to(root): + parser.error("output name must be confined to OBSERVER_OUT_DIR") + if args.command == "normalize-msvc": + normalize_msvc(Path(args.input), output, args.automation_id) + elif args.command == "convert-tidy": + clang_tidy_to_sarif(Path(args.repository), Path(args.log_root), output, args.automation_id) + else: + merge_sarif([Path(path) for path in args.inputs], output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/core/sign.py b/build/core/sign.py new file mode 100644 index 0000000..cd0820f --- /dev/null +++ b/build/core/sign.py @@ -0,0 +1,83 @@ +"""Canonical MD5 identities for rendered content-addressed recipes.""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from typing import TypeAlias + + +JsonScalar: TypeAlias = None | bool | int | float | str +JsonValue: TypeAlias = ( + JsonScalar | list["JsonValue"] | tuple["JsonValue", ...] | Mapping[str, "JsonValue"] +) +_MD5_UID = re.compile(r"[0-9a-f]{32}") + + +def _items( + mapping: Mapping[str, object], description: str +) -> list[tuple[str, object]]: + result: list[tuple[str, object]] = [] + for key, value in mapping.items(): + if not isinstance(key, str): + raise TypeError(f"{description} must be strings") + result.append((key, value)) + return sorted(result) + + +def _identity(value: JsonValue) -> JsonValue: + if isinstance(value, Mapping): + return { + key: _identity(item) + for key, item in _items(value, "identity mapping keys") + } + if isinstance(value, (list, tuple)): + return [_identity(item) for item in value] + if value is None or isinstance(value, (bool, int, float, str)): + return value + raise TypeError(f"unsupported identity value: {type(value).__name__}") + + +def content_uid( + *, + recipe: str | bytes, + inputs: Mapping[str, bytes], + dependencies: Mapping[str, str], + toolchain: Mapping[str, JsonValue], + config: Mapping[str, JsonValue], +) -> str: + """Hash one unambiguous canonical-JSON description of a node.""" + + recipe_bytes = recipe.encode("utf-8") if isinstance(recipe, str) else recipe + if type(recipe_bytes) is not bytes: + raise TypeError("recipe must be str or bytes") + + input_data: list[list[str]] = [] + for name, data in _items(inputs, "input names"): + if type(data) is not bytes: + raise TypeError("input contents must be bytes") + input_data.append([name, data.hex()]) + + dependency_data: list[list[str]] = [] + for name, uid in _items(dependencies, "dependency names"): + if not isinstance(uid, str) or _MD5_UID.fullmatch(uid) is None: + raise ValueError(f"dependency {name!r} does not have a canonical MD5 UID") + dependency_data.append([name, uid]) + + payload = json.dumps( + { + "config": _identity(config), + "dependencies": dependency_data, + "format": "observer-build-content-v1", + "inputs": input_data, + "recipe": recipe_bytes.hex(), + "toolchain": _identity(toolchain), + }, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.md5(payload, usedforsecurity=False).hexdigest() diff --git a/build/core/source_tools.py b/build/core/source_tools.py new file mode 100644 index 0000000..0bdc086 --- /dev/null +++ b/build/core/source_tools.py @@ -0,0 +1,69 @@ +"""Discover and fingerprint the repository source-check tools.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import shutil + +from core.toolchain import MsvcToolchain, _output + + +@dataclass(frozen=True, slots=True) +class SourceTools: + pwsh: Path + clang_format: Path + cppcheck: Path + psscriptanalyzer: Path + vcpkg_root: Path + environment: tuple[tuple[str, str], ...] + identity: tuple[tuple[str, str], ...] + + +def discover_source_tools(toolchain: MsvcToolchain) -> SourceTools: + """Locate source analyzers and capture their exact cache identity.""" + + pwsh = toolchain.pwsh.resolve(strict=True) + clang_format = (toolchain.llvm_dir / "bin/clang-format.exe").resolve(strict=True) + candidate = shutil.which("cppcheck.exe") + if candidate is None: + raise FileNotFoundError("cppcheck.exe was not found on PATH") + cppcheck = Path(candidate).resolve(strict=True) + vcpkg_root = toolchain.vcpkg_root.resolve(strict=True) + pwsh_version = _output([str(pwsh), "--version"]) + clang_format_version = _output([str(clang_format), "--version"]) + cppcheck_version = _output([str(cppcheck), "--version"]) + + pssa_query = """ +$module = Get-Module -ListAvailable PSScriptAnalyzer | + Sort-Object Version -Descending | + Select-Object -First 1 +if (-not $module) { throw 'PSScriptAnalyzer was not found' } +[ordered]@{ Path = $module.Path; Version = $module.Version.ToString() } | + ConvertTo-Json -Compress +""" + pssa_document = json.loads( + _output([str(pwsh), "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", pssa_query]) + ) + psscriptanalyzer = Path(pssa_document["Path"]).resolve(strict=True) + identity = { + "clang_format": str(clang_format), + "clang_format_version": clang_format_version, + "cppcheck": str(cppcheck), + "cppcheck_version": cppcheck_version, + "psscriptanalyzer": str(psscriptanalyzer), + "psscriptanalyzer_version": str(pssa_document["Version"]), + "pwsh": str(pwsh), + "pwsh_version": pwsh_version, + "vcpkg_root": str(vcpkg_root), + } + return SourceTools( + pwsh, + clang_format, + cppcheck, + psscriptanalyzer, + vcpkg_root, + toolchain.environment, + tuple(identity.items()), + ) diff --git a/build/core/store.py b/build/core/store.py new file mode 100644 index 0000000..5dc60eb --- /dev/null +++ b/build/core/store.py @@ -0,0 +1,106 @@ +"""Repository-local CAS using pg83/ix's zero-byte publication marker.""" + +from __future__ import annotations + +import os +from pathlib import Path +import stat + +from core.graph import Node +from core.paths import BuildPaths, CasPaths + + +class CasStateError(RuntimeError): + """A CAS entry could not be prepared or safely published.""" + + +def _lstat(path: Path) -> os.stat_result | None: + try: + return os.lstat(path) + except FileNotFoundError: + return None + + +def _lstat_as(path: Path, file_type: int) -> os.stat_result | None: + information = _lstat(path) + if information is None or stat.S_IFMT(information.st_mode) != file_type: + return None + return information + + +def _complete(paths: CasPaths) -> bool: + marker = _lstat_as(paths.touch, stat.S_IFREG) + return ( + _lstat_as(paths.entry, stat.S_IFDIR) is not None + and _lstat_as(paths.output, stat.S_IFDIR) is not None + and _lstat_as(paths.log, stat.S_IFREG) is not None + and marker is not None + and marker.st_size == 0 + ) + + +class CasStore: + """Own completion state for one build run; callers hold the UID lock.""" + + def __init__(self, paths: BuildPaths, run_identifier: str) -> None: + self._paths = paths + self._run_work = paths.run_work(run_identifier) + + def paths_for(self, current: Node) -> CasPaths: + return self._paths.cas(current.uid) + + def is_complete(self, current: Node) -> bool: + return _complete(self.paths_for(current)) + + def prepare_entry(self, current: Node) -> CasPaths: + paths = self.paths_for(current) + if _complete(paths): + return paths + + self._prepare_run_directory() + if _lstat(paths.entry) is not None: + self._quarantine(paths) + + paths.entry.mkdir(exist_ok=False) + paths.output.mkdir(exist_ok=False) + with paths.log.open("xb"): + pass + return paths + + def mark_complete(self, current: Node) -> None: + paths = self.paths_for(current) + if _lstat_as(paths.entry, stat.S_IFDIR) is None or _lstat_as( + paths.output, stat.S_IFDIR + ) is None: + raise CasStateError("cannot publish completion without output directory") + if _lstat_as(paths.log, stat.S_IFREG) is None: + raise CasStateError("cannot publish completion without a regular log file") + + try: + with paths.touch.open("xb"): + pass + except FileExistsError as error: + raise CasStateError("completion marker already exists") from error + + def _prepare_run_directory(self) -> None: + self._paths.require_confined(self._run_work, self._paths.work_root) + self._run_work.mkdir(exist_ok=True) + + def _quarantine(self, paths: CasPaths) -> None: + quarantine_root = self._paths.require_confined( + self._run_work / "quarantine", self._paths.work_root + ) + quarantine_root.mkdir(exist_ok=True) + destination = self._paths.require_confined( + quarantine_root / paths.entry.name, self._paths.work_root + ) + if _lstat(destination) is not None: + raise CasStateError( + f"quarantine destination already exists: {destination}" + ) + try: + paths.entry.rename(destination) + except OSError as error: + raise CasStateError( + f"could not quarantine incomplete CAS entry {paths.entry}" + ) from error diff --git a/build/core/toolchain.py b/build/core/toolchain.py new file mode 100644 index 0000000..68b98a5 --- /dev/null +++ b/build/core/toolchain.py @@ -0,0 +1,152 @@ +"""Discover the installed x64 MSVC analysis toolchain.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +import shutil +import subprocess + + +@dataclass(frozen=True, slots=True) +class MsvcToolchain: + installation: Path + msbuild: Path + vsdevcmd: Path + llvm_dir: Path + clang_tidy: Path + vcpkg_root: Path + pwsh: Path + environment: tuple[tuple[str, str], ...] + identity: tuple[tuple[str, str], ...] + + +_DEVELOPER_VARIABLES = frozenset( + { + "devenvdir", + "extensionsdkdir", + "external_include", + "include", + "lib", + "libpath", + "netfxsdkdir", + "ucrtversion", + "universalcrtsdkdir", + "vcideinstalldir", + "vcinstalldir", + "visualstudioversion", + "vs170comntools", + "vsinstalldir", + "windowslibpath", + } +) +_DEVELOPER_PREFIXES = ("framework", "vctools", "vscmd_", "windowssdk") + + +def _developer_variable(name: str) -> bool: + return name in _DEVELOPER_VARIABLES or name.startswith(_DEVELOPER_PREFIXES) + + +def _existing(path: Path | str | None, directory: bool = False) -> Path: + resolved = Path(path).resolve() if path else None + if resolved is None or not (resolved.is_dir() if directory else resolved.is_file()): + raise FileNotFoundError(f"required path not found: {path}") + return resolved + + +def _command_environment(text: str) -> tuple[tuple[str, str], ...]: + values: dict[str, tuple[str, str]] = {} + for line in text.splitlines(): + if not line or line.startswith("="): + continue + key, value = line.split("=", 1) + folded = key.casefold() + if not _developer_variable(folded): + continue + canonical = key.upper() if folded in {"path", "lib"} else key + if folded == "lib": + value = os.pathsep.join( + part for part in value.split(os.pathsep) if Path(part).is_dir() + ) + values[folded] = (canonical, value) + + return tuple(sorted(values.values(), key=lambda pair: pair[0].casefold())) + + +def _output(argv: list[str] | str, **options: str) -> str: + output = subprocess.run( + argv, check=True, capture_output=True, text=True, **options + ).stdout.strip() + if not output: + raise RuntimeError(f"tool returned empty output: {argv[0]}") + return output +def discover_msvc_toolchain() -> MsvcToolchain: + """Locate Visual Studio tools and capture an isolated amd64 developer environment.""" + + components = ( + "Microsoft.Component.MSBuild", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "Microsoft.VisualStudio.Component.VC.Llvm.Clang", + ) + vswhere = _existing( + Path(os.environ["ProgramFiles(x86)"]) + / "Microsoft Visual Studio" + / "Installer" + / "vswhere.exe" + ) + installation = _existing( + _output( + [ + str(vswhere), + "-latest", + "-products", + "*", + "-requires", + *components, + "-property", + "installationPath", + ] + ), + directory=True, + ) + bin_dir = installation / "MSBuild" / "Current" / "Bin" + amd64_msbuild = bin_dir / "amd64" / "MSBuild.exe" + msbuild = _existing(amd64_msbuild if amd64_msbuild.is_file() else bin_dir / "MSBuild.exe") + vsdevcmd = _existing(installation / "Common7" / "Tools" / "VsDevCmd.bat") + llvm_dir = _existing(installation / "VC" / "Tools" / "Llvm" / "x64", directory=True) + clang_tidy = _existing(llvm_dir / "bin" / "clang-tidy.exe") + cmd = _existing(Path(os.environ.get("SystemRoot", "")) / "System32" / "cmd.exe") + payload = f'call "{vsdevcmd}" -no_logo -arch=amd64 -host_arch=amd64 >nul && set' + environment = _command_environment( + _output(f'"{cmd}" /d /s /c "{payload}"', executable=str(cmd)) + ) + msbuild_version = _output([str(msbuild), "-version", "-nologo"]) + clang_output = _output([str(clang_tidy), "--version"]) + clang_tidy_version = next( + line.partition("LLVM version")[2].strip() + for line in clang_output.splitlines() + if "LLVM version" in line + ) + vcpkg_root = _existing( + os.environ.get("VCPKG_ROOT") + or _existing(shutil.which("vcpkg")).parent, + directory=True, + ) + pwsh = _existing(shutil.which("pwsh")) + captured = {key.casefold(): value for key, value in environment} + identity = ( + ("clang_tidy", str(clang_tidy)), + ("clang_tidy_version", clang_tidy_version), + ("installation", str(installation)), + ("msbuild", str(msbuild)), + ("msbuild_version", msbuild_version), + ("pwsh", str(pwsh)), + ("vc_tools_version", captured.get("vctoolsversion", "")), + ("vcpkg_root", str(vcpkg_root)), + ("vsdevcmd", str(vsdevcmd)), + ("vsdevcmd_version", captured.get("vscmd_ver", "")), + ("windows_sdk_version", captured.get("windowssdkversion", "")), + ) + return MsvcToolchain(installation, msbuild, vsdevcmd, llvm_dir, clang_tidy, + vcpkg_root, pwsh, environment, identity) diff --git a/build/core/windows_job.py b/build/core/windows_job.py new file mode 100644 index 0000000..3ec68d6 --- /dev/null +++ b/build/core/windows_job.py @@ -0,0 +1,44 @@ +"""Minimal kill-on-close Windows Job Object wrapper.""" + +from __future__ import annotations + +import win32job + + +class WindowsJob: + """Own one Job handle until an explicit, observable close.""" + + def __init__(self) -> None: + handle = win32job.CreateJobObject(None, "") + self._handle = handle + try: + information = win32job.QueryInformationJobObject( + handle, win32job.JobObjectExtendedLimitInformation + ) + information["BasicLimitInformation"]["LimitFlags"] |= ( + win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ) + win32job.SetInformationJobObject( + handle, + win32job.JobObjectExtendedLimitInformation, + information, + ) + except BaseException as error: + self._handle = None + try: + handle.Close() + except BaseException as cleanup_error: + error.add_note(f"cleanup failure: {cleanup_error!r}") + raise + + def assign_process(self, process_handle: int) -> None: + win32job.AssignProcessToJobObject(self._handle, process_handle) + + def terminate(self) -> None: + win32job.TerminateJobObject(self._handle, 1) + + def close(self) -> None: + handle = self._handle + if handle is not None: + handle.Close() + self._handle = None diff --git a/build/core/windows_process.py b/build/core/windows_process.py new file mode 100644 index 0000000..c5961ed --- /dev/null +++ b/build/core/windows_process.py @@ -0,0 +1,122 @@ +"""Small psutil-backed Windows process-tree runner.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import os +from pathlib import Path +import subprocess +from typing import BinaryIO + +import psutil + +from core.graph import Command +from core.windows_job import WindowsJob + + +_CREATE_SUSPENDED = 0x00000004 +_CREATE_UNICODE_ENVIRONMENT = 0x00000400 + + +def _attempt( + errors: list[BaseException], operation: Callable[[], object] +) -> bool: + try: + operation() + return True + except BaseException as error: + errors.append(error) + return False + + +def _stop( + job: WindowsJob, + process: psutil.Popen[bytes] | None, + assigned: bool, + errors: list[BaseException], +) -> None: + closed = _attempt(errors, job.close) + tree_stopped = assigned and closed + if assigned and not closed: + tree_stopped = _attempt(errors, job.terminate) + if process is not None and not tree_stopped: + _attempt(errors, process.kill) + + +async def _reap( + process: psutil.Popen[bytes], + communication: asyncio.Task[object] | None, + errors: list[BaseException], + primary: BaseException, +) -> None: + if communication is not None: + try: + await asyncio.shield(communication) + except BaseException as error: + if error is not primary: + errors.append(error) + if process.returncode is None: + await asyncio.to_thread(_attempt, errors, process.wait) + + +class WindowsProcessRunner: + """Run literal argv in a kill-on-close Job and stream exact stdin.""" + + async def run(self, command: Command, *, log: BinaryIO) -> int: + executable = Path(command.argv[0]) + if not executable.is_absolute() or not executable.is_file(): + raise ValueError("command executable must be an absolute existing file") + + job = WindowsJob() + process: psutil.Popen[bytes] | None = None + communication: asyncio.Task[object] | None = None + assigned = False + try: + environment = { + key.casefold(): (key, value) for key, value in os.environ.items() + } + for key, value in command.env: + folded = key.casefold() + if folded == "path" and folded in environment: + value += os.pathsep + environment[folded][1] + environment[folded] = (key, value) + process = psutil.Popen( + list(command.argv), + executable=command.argv[0], + shell=False, + stdin=subprocess.PIPE, + stdout=log, + stderr=subprocess.STDOUT, + cwd=command.cwd, + env=dict(environment.values()), + close_fds=True, + creationflags=_CREATE_SUSPENDED | _CREATE_UNICODE_ENVIRONMENT, + ) + job.assign_process(int(process._handle)) + assigned = True + process.resume() + communication = asyncio.create_task( + asyncio.to_thread(process.communicate, input=command.stdin) + ) + await asyncio.shield(communication) + if process.returncode is None: + raise RuntimeError("process completed without an exit code") + exit_code = process.returncode + except BaseException as primary: + errors: list[BaseException] = [] + _stop(job, process, assigned, errors) + if process is not None: + await _reap(process, communication, errors, primary) + for error in errors: + primary.add_note(f"cleanup failure: {error!r}") + raise + + errors = [] + _stop(job, process, assigned, errors) + if errors: + primary, *cleanup_errors = errors + for error in cleanup_errors: + primary.add_note(f"cleanup failure: {error!r}") + raise primary + return exit_code diff --git a/build/driver.py b/build/driver.py new file mode 100644 index 0000000..08ec51f --- /dev/null +++ b/build/driver.py @@ -0,0 +1,509 @@ +"""Thin command orchestration over the independently tested build graphs.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from pathlib import Path +import psutil + +from core.clean import sweep_cas +from core.graph import Graph, merge_graphs +from core.host import require_runnable, runnable_architectures, verify_route +from core.node import NodeFactory +from core.quality_tools import ( + ResolvedTool, + resolve_binskim, + resolve_dumpbin, + resolve_asan_runtimes, + resolve_umdh, + resolve_ubsan_runtime, +) +from core.runtime import BuildRuntime +from core.source_tools import SourceTools, discover_source_tools +from core.toolchain import MsvcToolchain +from core.render import TemplateRenderer +from core.result_export import export_results +from graphs.analysis import (analysis_discovery_slice, analysis_slice, + load_dependency_manifests) +from graphs.audit import audit_graph +from graphs.common import BUILD_ROOT, require_positive_integers, restore_node, tool_environment +from graphs.coverage import coverage_dependency_discovery_slice, coverage_graph +from graphs.fuzz import ( + FUZZ_TARGETS, + FuzzCorpusArtifact, + fuzz_dependency_discovery_slice, + fuzz_graph, +) +from graphs.python_coverage import python_coverage_graph +from graphs.sanitizer import ( + AsanRuntime, + sanitizer_dependency_discovery_slice, + sanitizer_graph, +) +from graphs.leak import leak_graph +from graphs.native import native_dependency_discovery_slice, native_graph +from graphs.package import package_graph, package_outputs +from graphs.source import architecture_source_checks, common_source_checks, source_checks as source_checks_graph + + +_MODULES = ("renpy", "rpgmaker", "zanzarah") +_SANITIZER_ARCHITECTURES = { + "asan": ("ASan", frozenset(("x86", "x64"))), + "ubsan": ("UBSan", frozenset(("x64",))), +} + + +def _sanitizer_selections( + sanitizer: str, architectures: tuple[str, ...] +) -> tuple[tuple[str, str], ...]: + label, supported = _SANITIZER_ARCHITECTURES[sanitizer] + unsupported = next( + (architecture for architecture in architectures if architecture not in supported), + None, + ) + if unsupported is not None: + raise ValueError(f"{label} does not support {unsupported}") + return tuple((sanitizer, architecture) for architecture in require_runnable(architectures)) + + +class Driver: + """Own one repository-local runtime and compose command-specific graph families.""" + + def __init__(self, repository: Path, run_id: str, toolchain: MsvcToolchain, + *, jobs: int | None = None, prune_cas: bool = False) -> None: + self.repository = repository.resolve(strict=True) + self.jobs = (psutil.cpu_count() or 1) if jobs is None else jobs + require_positive_integers((self.jobs,), "jobs must be a positive integer") + self.toolchain = toolchain + self.runtime = BuildRuntime(self.repository, run_id) + self._last_graph: Graph | None = None + self._prune_cas = prune_cas + self._prune_ready = False + + def _sweep_cas(self) -> None: + if self._prune_cas and self._prune_ready and self._last_graph is not None: + sweep_cas( + self.repository, self.runtime.live_uids(self._last_graph), + owned_run_id=self.runtime.owned_run_id, + ) + + async def _run(self, graph: Graph) -> tuple[Path, ...]: + self._last_graph = graph + await self.runtime.executor(graph).run() + return tuple(self.runtime.store.paths_for(graph.node(name)).output for name in graph.targets) + + async def _run_final(self, graph: Graph) -> tuple[Path, ...]: + self._prune_ready = True + return await self._run(graph) + + async def _public( + self, command: str, export_dir: Path | None, + action: Callable[[], Awaitable[tuple[Path, ...]]], + ) -> tuple[Path, ...]: + self._last_graph = None + self._prune_ready = False + async with self.runtime.session(): + try: + outputs = await action() + except Exception as error: + if export_dir is not None and self._last_graph is not None: + failures = tuple(getattr(error, "failed_nodes", ())) + try: + export_results( + self._last_graph, self.runtime.store, export_dir, + command, "failed", failures=failures, + cache=self.runtime.cache_report(self._last_graph), + ) + except Exception as export_error: + raise ExceptionGroup( + f"{command} and result export failed", (error, export_error) + ) from None + try: + self._sweep_cas() + except Exception as prune_error: + raise ExceptionGroup( + f"{command} and cache pruning failed", (error, prune_error) + ) from None + raise + try: + self._sweep_cas() + except Exception as prune_error: + if export_dir is not None: + assert self._last_graph is not None + try: + export_results( + self._last_graph, self.runtime.store, export_dir, + command, "failed", failures=(), + cache=self.runtime.cache_report(self._last_graph), + ) + except Exception as export_error: + raise ExceptionGroup( + "cache pruning and result export failed", + (prune_error, export_error), + ) from None + raise + if export_dir is not None: + assert self._last_graph is not None + export_results( + self._last_graph, self.runtime.store, export_dir, + command, "success", failures=(), + cache=self.runtime.cache_report(self._last_graph), + ) + return outputs + + async def _staged(self, discovery: Graph, compose: Callable[..., Graph], + **options: object) -> Graph: + await self._run(discovery) + return compose( + self.repository, self.toolchain, discovery=discovery, + manifests=load_dependency_manifests(self.repository, discovery), + jobs=self.jobs, **options, + ) + + async def _native(self, architectures: tuple[str, ...], configurations: tuple[str, ...], + runnable: tuple[str, ...], *, test_shards: int = 4, + include_leak_probe: bool = False, corpus: Path | None = None, + run_nonce: str = "") -> Graph: + discovery = native_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=architectures, + configurations=configurations, include_leak_probe=include_leak_probe, + ) + return await self._staged( + discovery, native_graph, architectures=architectures, configurations=configurations, + runnable_architectures=runnable, test_shards=test_shards, + include_leak_probe=include_leak_probe, corpus=corpus, run_nonce=run_nonce, + ) + + async def _audited_release(self, architectures: tuple[str, ...], dumpbin: ResolvedTool, + binskim: ResolvedTool, *, + include_leak_probe: bool = False) -> Graph: + upstream = await self._native( + architectures, ("Release",), (), include_leak_probe=include_leak_probe + ) + return audit_graph( + self.repository, upstream, architectures=architectures, + include_leak_probe=include_leak_probe, + dumpbin=dumpbin.path, dumpbin_identity=dict(dumpbin.identity), + binskim=binskim.path, binskim_identity=dict(binskim.identity), + jobs=self.jobs, + ) + + async def restore(self, architectures: tuple[str, ...] = ("x64",), + flavors: tuple[str, ...] = ("",)) -> tuple[Path, ...]: + factory = NodeFactory( + TemplateRenderer(BUILD_ROOT / "templates"), self.repository, {}, + tool_environment(self.toolchain), + ) + nodes = tuple( + restore_node(self.repository, self.toolchain, factory, architecture, flavor=flavor) + for flavor in flavors for architecture in architectures + ) + return await self._run( + Graph(nodes, tuple(node.name for node in nodes), {"restore": 1}) + ) + + async def build(self, architectures: tuple[str, ...] = ("x64",), + configurations: tuple[str, ...] = ("Debug",)) -> tuple[Path, ...]: + return await self._run(await self._native(architectures, configurations, ())) + + async def test(self, architectures: tuple[str, ...] = ("x64",), + configurations: tuple[str, ...] = ("Debug",), *, test_shards: int = 4, + corpus: Path | None = None, run_nonce: str = "") -> tuple[Path, ...]: + graph = await self._native( + architectures, configurations, require_runnable(architectures), + test_shards=test_shards, corpus=corpus, run_nonce=run_nonce, + ) + return await self._run(graph) + + async def source_checks(self, architectures: tuple[str, ...], + tools: SourceTools) -> tuple[Path, ...]: + graph = source_checks_graph( + self.repository, tools, jobs=self.jobs, architectures=architectures + ) + return await self._run(graph) + + async def compiler_analysis(self, architectures: tuple[str, ...] = ("x64",)) -> tuple[Path, ...]: + discovery = analysis_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=architectures + ) + graph = await self._staged(discovery, analysis_slice, architectures=architectures) + return await self._run(graph) + + async def test_coverage( + self, architectures: tuple[str, ...] = ("x64",), *, test_shards: int = 4, + report_jobs: int = 2, corpus: Path | None = None, run_nonce: str = "", + ) -> tuple[Path, ...]: + if corpus is not None and ( + not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce + ): + raise ValueError("corpus run nonce must be a non-empty string without NUL") + runnable = require_runnable(architectures) + discovery = coverage_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=runnable + ) + graph = await self._staged( + discovery, coverage_graph, architectures=runnable, + test_shards=test_shards, report_jobs=report_jobs, + corpus=corpus, run_nonce=run_nonce, + ) + return await self._run(graph) + + async def _sanitizer( + self, selections: tuple[tuple[str, str], ...], *, llvm_runtime: Path | None, + llvm_runtime_identity: dict[str, str], asan_runtimes: tuple[AsanRuntime, ...], + test_shards: int, + ) -> tuple[Path, ...]: + discovery = sanitizer_dependency_discovery_slice( + self.repository, self.toolchain, + llvm_runtime=llvm_runtime, llvm_runtime_identity=llvm_runtime_identity, + selections=selections, jobs=self.jobs, + ) + graph = await self._staged( + discovery, sanitizer_graph, + llvm_runtime=llvm_runtime, llvm_runtime_identity=llvm_runtime_identity, + asan_runtimes=asan_runtimes, selections=selections, + test_shards=test_shards, + ) + return await self._run(graph) + + async def test_asan( + self, architectures: tuple[str, ...] = ("x64",), *, test_shards: int = 4, + ) -> tuple[Path, ...]: + selections = _sanitizer_selections("asan", architectures) + runtimes = tuple( + AsanRuntime(architecture, tool.path, dict(tool.identity)) + for architecture, tool in resolve_asan_runtimes( + self.toolchain, tuple(architecture for _, architecture in selections) + ) + ) + return await self._sanitizer( + selections, llvm_runtime=None, llvm_runtime_identity={}, + asan_runtimes=runtimes, test_shards=test_shards, + ) + + async def test_ubsan( + self, architectures: tuple[str, ...] = ("x64",), *, test_shards: int = 4, + ) -> tuple[Path, ...]: + selections = _sanitizer_selections("ubsan", architectures) + runtime = resolve_ubsan_runtime(self.toolchain) + return await self._sanitizer( + selections, llvm_runtime=runtime.path, + llvm_runtime_identity=dict(runtime.identity), asan_runtimes=(), + test_shards=test_shards, + ) + + async def python_coverage(self) -> tuple[Path, ...]: + return await self._run(python_coverage_graph(self.repository)) + + async def fuzz(self, *, run_nonce: str, seconds: int = 60, fuzz_jobs: int = 2, + prior_corpora: tuple[FuzzCorpusArtifact, ...] = (), + targets: tuple[str, ...] = FUZZ_TARGETS) -> tuple[Path, ...]: + require_runnable(("x64",)) + discovery = fuzz_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, targets=targets + ) + graph = await self._staged( + discovery, fuzz_graph, run_nonce=run_nonce, seconds=seconds, + fuzz_jobs=fuzz_jobs, prior_corpora=prior_corpora, targets=targets, + ) + return await self._run(graph) + + async def audit(self, architectures: tuple[str, ...] = ("x64",), *, + dumpbin: ResolvedTool, binskim: ResolvedTool) -> tuple[Path, ...]: + return await self._run( + await self._audited_release(architectures, dumpbin, binskim) + ) + + async def _package(self, architectures: tuple[str, ...], *, + dumpbin: ResolvedTool, binskim: ResolvedTool) -> tuple[Path, ...]: + audited = await self._audited_release(architectures, dumpbin, binskim) + graph = package_graph( + self.repository, audited, architectures=architectures, + smoke_architectures=runnable_architectures(architectures), jobs=self.jobs, + ) + await self._run_final(graph) + return package_outputs(self.repository, graph) + + async def package(self, architectures: tuple[str, ...] = ("x64",), *, + dumpbin: ResolvedTool, binskim: ResolvedTool, + export_dir: Path | None = None) -> tuple[Path, ...]: + return await self._public( + "package", export_dir, + lambda: self._package(architectures, dumpbin=dumpbin, binskim=binskim), + ) + + async def test_leaks(self, *, run_nonce: str, dumpbin: ResolvedTool, + binskim: ResolvedTool, umdh: ResolvedTool, + warmup: int = 8, iterations: int = 100, windows: int = 3, + tolerance_bytes: int = 0) -> tuple[Path, ...]: + require_runnable(("x64",)) + audited = await self._audited_release( + ("x64",), dumpbin, binskim, include_leak_probe=True + ) + graph = leak_graph( + self.repository, audited, + umdh=umdh.path, umdh_identity=dict(umdh.identity), run_nonce=run_nonce, + warmup=warmup, iterations=iterations, windows=windows, + tolerance_bytes=tolerance_bytes, jobs=self.jobs, + ) + return await self._run(graph) + + async def _verify( + self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None, + run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4, + warmup: int = 8, iterations: int = 100, windows: int = 3, + tolerance_bytes: int = 0, include_common: bool, + ) -> tuple[Path, ...]: + """Run every host-capable gate in one shared-pool graph after one discovery union.""" + + route = verify_route(architectures) + source_tools = discover_source_tools(self.toolchain) + dumpbin, binskim = resolve_dumpbin(self.toolchain), resolve_binskim() + asan_tools = resolve_asan_runtimes(self.toolchain, route.asan) if route.asan else () + ubsan = resolve_ubsan_runtime(self.toolchain) if route.ubsan else None + umdh = resolve_umdh() if route.run_x64_specialists else None + selections = tuple(("asan", item) for item in route.asan) + tuple( + ("ubsan", item) for item in route.ubsan + ) + llvm_runtime = ubsan.path if ubsan else None + llvm_identity = dict(ubsan.identity) if ubsan else {} + asan_runtimes = tuple( + AsanRuntime(architecture, tool.path, dict(tool.identity)) + for architecture, tool in asan_tools + ) + + discoveries = [ + analysis_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=architectures + ), + native_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, architectures=architectures, + configurations=("Debug", "Release"), + include_leak_probe=route.run_x64_specialists, + ), + ] + if route.coverage: + discoveries.append(coverage_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, + architectures=route.coverage, + )) + if selections: + discoveries.append(sanitizer_dependency_discovery_slice( + self.repository, self.toolchain, llvm_runtime=llvm_runtime, + llvm_runtime_identity=llvm_identity, selections=selections, jobs=self.jobs, + )) + if route.run_x64_specialists: + discoveries.append(fuzz_dependency_discovery_slice( + self.repository, self.toolchain, jobs=self.jobs, targets=FUZZ_TARGETS, + )) + discovery = merge_graphs(*discoveries) + await self._run(discovery) + manifests = load_dependency_manifests(self.repository, discovery) + + native = native_graph( + self.repository, self.toolchain, discovery=discovery, manifests=manifests, + jobs=self.jobs, architectures=architectures, + configurations=("Debug", "Release"), runnable_architectures=route.runnable, + test_shards=test_shards, include_leak_probe=route.run_x64_specialists, + corpus=corpus, run_nonce=run_nonce, + ) + audit = audit_graph( + self.repository, native, architectures=architectures, + dumpbin=dumpbin.path, dumpbin_identity=dict(dumpbin.identity), + binskim=binskim.path, binskim_identity=dict(binskim.identity), + jobs=self.jobs, + ) + package = package_graph( + self.repository, audit, architectures=architectures, + smoke_architectures=route.runnable, jobs=self.jobs, + ) + graphs = [ + native, + analysis_slice( + self.repository, self.toolchain, discovery=discovery, manifests=manifests, + jobs=self.jobs, architectures=architectures, + ), + architecture_source_checks( + self.repository, source_tools, architectures, jobs=self.jobs, + ), + package, + ] + if include_common: + graphs.extend(( + common_source_checks(self.repository, source_tools, jobs=self.jobs), + python_coverage_graph(self.repository), + )) + if route.coverage: + graphs.append(coverage_graph( + self.repository, self.toolchain, discovery=discovery, manifests=manifests, + jobs=self.jobs, architectures=route.coverage, test_shards=test_shards, + corpus=corpus, run_nonce=run_nonce, + )) + if selections: + graphs.append(sanitizer_graph( + self.repository, self.toolchain, discovery=discovery, manifests=manifests, + llvm_runtime=llvm_runtime, llvm_runtime_identity=llvm_identity, + asan_runtimes=asan_runtimes, selections=selections, + jobs=self.jobs, test_shards=test_shards, + )) + if route.run_x64_specialists: + assert umdh is not None + graphs.extend(( + fuzz_graph( + self.repository, self.toolchain, discovery=discovery, + manifests=manifests, run_nonce=run_nonce, seconds=fuzz_seconds, + jobs=self.jobs, targets=FUZZ_TARGETS, + ), + leak_graph( + self.repository, audit, + umdh=umdh.path, umdh_identity=dict(umdh.identity), + run_nonce=run_nonce, warmup=warmup, iterations=iterations, + windows=windows, tolerance_bytes=tolerance_bytes, jobs=self.jobs, + ), + )) + return await self._run_final(merge_graphs(*graphs)) + + async def verify_source(self, *, export_dir: Path | None = None) -> tuple[Path, ...]: + async def operation() -> tuple[Path, ...]: + tools = discover_source_tools(self.toolchain) + graph = merge_graphs( + common_source_checks(self.repository, tools, jobs=self.jobs), + python_coverage_graph(self.repository), + ) + return await self._run_final(graph) + + return await self._public("verify-source", export_dir, operation) + + async def verify_arch( + self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None, + run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4, + warmup: int = 8, iterations: int = 100, windows: int = 3, + tolerance_bytes: int = 0, export_dir: Path | None = None, + ) -> tuple[Path, ...]: + if len(architectures) != 1: + raise ValueError("verify-arch requires exactly one architecture") + return await self._public( + "verify-arch", export_dir, + lambda: self._verify( + architectures, corpus=corpus, run_nonce=run_nonce, + fuzz_seconds=fuzz_seconds, test_shards=test_shards, + warmup=warmup, iterations=iterations, windows=windows, + tolerance_bytes=tolerance_bytes, include_common=False, + ), + ) + + async def verify( + self, architectures: tuple[str, ...] = ("x64",), *, corpus: Path | None = None, + run_nonce: str, fuzz_seconds: int = 60, test_shards: int = 4, + warmup: int = 8, iterations: int = 100, windows: int = 3, + tolerance_bytes: int = 0, export_dir: Path | None = None, + ) -> tuple[Path, ...]: + return await self._public( + "verify", export_dir, + lambda: self._verify( + architectures, corpus=corpus, run_nonce=run_nonce, + fuzz_seconds=fuzz_seconds, test_shards=test_shards, + warmup=warmup, iterations=iterations, windows=windows, + tolerance_bytes=tolerance_bytes, include_common=True, + ), + ) diff --git a/build/graphs/__init__.py b/build/graphs/__init__.py new file mode 100644 index 0000000..7e6586f --- /dev/null +++ b/build/graphs/__init__.py @@ -0,0 +1 @@ +"""Repository build graphs.""" diff --git a/build/graphs/analysis.py b/build/graphs/analysis.py new file mode 100644 index 0000000..9159fcd --- /dev/null +++ b/build/graphs/analysis.py @@ -0,0 +1,532 @@ +"""Compiler-derived, translation-unit-grained analysis graphs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import json +from pathlib import Path +import xml.etree.ElementTree as ET + +from core.graph import Graph, Node, Result +from core.node import NodeFactory +from core.paths import BuildPaths +from core.quality_tools import ResolvedTool, resolve_llvm +from core.toolchain import MsvcToolchain +from graphs.common import ( + COMMON_PROJECT_INPUTS, PLATFORMS, project_path, python_action, recipe_factory, + restore_node, tool_environment, +) + + +_BUILD_ROOT = Path(__file__).resolve().parents[1] +_MSBUILD_NS = "{http://schemas.microsoft.com/developer/msbuild/2003}" +_HEADER_SUFFIXES = frozenset({".h", ".hh", ".hpp", ".hxx", ".inc", ".inl"}) + + +@dataclass(frozen=True, slots=True) +class MsbuildProject: + name: str + path: Path + inputs: tuple[str, ...] + sources: tuple[Path, ...] + headers: tuple[Path, ...] + + +def _relative(repository: Path, path: Path) -> str: + return path.resolve(strict=True).relative_to(repository).as_posix() + + +def _platform(architecture: str) -> str: + try: + return PLATFORMS[architecture] + except KeyError as error: + raise ValueError(f"unsupported architecture: {architecture}") from error + + +def project_inventory( + repository: Path, names: tuple[str, ...] | None = None, *, + include_link_inputs: bool = True, +) -> tuple[MsbuildProject, ...]: + """Parse each selected project once into immutable signed inputs and sources.""" + + root = repository.resolve(strict=True) + paths = (tuple(sorted((root / "build/projects").glob("*.vcxproj"))) if names is None + else tuple(root / "build/projects" / f"{name}.vcxproj" for name in names)) + projects = [] + for project in paths: + document = ET.parse(project).getroot() + inputs = list(COMMON_PROJECT_INPUTS) + [project.relative_to(root).as_posix()] + if include_link_inputs: + inputs.extend( + project_path(root, item.text.strip(), project) for item in + document.iter(f"{_MSBUILD_NS}ModuleDefinitionFile") if item.text + ) + sources = tuple( + root / project_path(root, item.get("Include", ""), project) for item in + document.iter(f"{_MSBUILD_NS}ClCompile") if item.get("Include") + ) + headers = tuple( + root / project_path(root, item.get("Include", ""), project) for item in + document.iter(f"{_MSBUILD_NS}ClInclude") if item.get("Include") + ) + projects.append(MsbuildProject( + project.stem, project, tuple(dict.fromkeys(inputs)), sources, headers + )) + return tuple(projects) + + +def _projects(repository: Path) -> tuple[tuple[str, Path, Path, tuple[Path, ...]], ...]: + try: + projects = project_inventory(repository, include_link_inputs=False) + except ValueError as error: + message = str(error).replace("unsupported project input", "unsupported ClCompile path", 1) + raise ValueError(message) from error + return tuple( + (project.name, project.path, source, project.headers) for project in projects + for source in project.sources + ) + + +def _unit(repository: Path, source: Path) -> str: + return _relative(repository / "src", source).removesuffix(".cpp").replace("/", ".") + + +def dependency_node_name( + repository: Path, architecture: str, project_name: str, source: Path, + qualifier: str = "", +) -> str: + prefix = f"{architecture}-{qualifier}" if qualifier else architecture + return f"discover-dependencies-{prefix}-{project_name}-{_unit(repository, source)}" + + +def _project_files( + repository: Path, project_name: str, project: Path, source: Path, + extra: tuple[str, ...] = (), +) -> dict[str, bytes]: + inputs = list(COMMON_PROJECT_INPUTS) + [ + _relative(repository, project), _relative(repository, source) + ] + if project_name.startswith("fuzz-"): + inputs.append("build/ObserverFuzz.props") + inputs.extend(extra) + return {path: (repository / path).read_bytes() for path in dict.fromkeys(inputs)} + + +def _stable_headers(source: Path, project_headers: tuple[Path, ...]) -> tuple[Path, ...]: + local_headers = tuple( + path for path in source.parent.iterdir() + if path.is_file() and path.suffix.casefold() in _HEADER_SUFFIXES + ) + return tuple(dict.fromkeys((*project_headers, *local_headers))) + + +def _discovery_files( + repository: Path, + project_name: str, + project: Path, + source: Path, + headers: tuple[Path, ...], +) -> dict[str, bytes]: + extra = tuple( + _relative(repository, path) + for path in _stable_headers(source, headers) + ) + return _project_files(repository, project_name, project, source, extra) + + +def _compile_variables( + toolchain: MsvcToolchain, project: Path, source: Path, restore_output: Path, + configuration: str, platform: str, +) -> dict[str, object]: + return { + "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), + "project": str(project), "target": "ClCompile", + "configuration": configuration, "platform": platform, "msbuild_args": [], + "source": str(source), "vcpkg_root": str(toolchain.vcpkg_root), + "vcpkg_installed": str(restore_output), + } + + +def dependency_inputs( + repository: Path, restore_output: Path, source: Path, content: bytes, + project_headers: tuple[Path, ...] = (), +) -> dict[str, bytes]: + """Validate one manifest and sign its covered project/package dependency bytes.""" + + try: + data = json.loads(content)["Data"] + reported_source, includes = data["Source"], data["Includes"] + if not isinstance(reported_source, str) or not isinstance(includes, list) or not all( + isinstance(item, str) for item in includes + ): + raise TypeError + reported = Path(reported_source).resolve(strict=True) + dependencies = [Path(item) for item in includes] + if not all(path.is_absolute() for path in dependencies): + raise TypeError + except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError, OSError) as error: + raise ValueError(f"invalid MSVC dependency manifest for {source}") from error + expected = source.resolve(strict=True) + if reported != expected: + raise ValueError(f"MSVC dependency manifest source mismatch for {source}") + + files = {"compiler/dependencies.json": content} + try: + source_root = (repository / "src").resolve(strict=True) + restore_root = restore_output.resolve(strict=False) + covered = { + path.resolve(strict=True) + for path in _stable_headers(source, project_headers) + } + for candidate in dict.fromkeys((expected, *dependencies)): + resolved = candidate.resolve(strict=False) + if resolved.is_relative_to(source_root): + path = candidate.resolve(strict=True) + name = _relative(repository, path) + if path != expected and path not in covered: + raise ValueError( + "first-party dependency is not covered by the stable header " + f"inventory for {source}: {name}" + ) + elif resolved.is_relative_to(restore_root): + path = candidate.resolve(strict=True) + name = "vcpkg/" + path.relative_to(restore_root).as_posix() + else: + continue + files[name] = path.read_bytes() + except OSError as error: + raise ValueError(f"invalid MSVC dependency manifest for {source}") from error + return files + + +def project_build( + repository: Path, factory: NodeFactory, discovery: Graph, + manifests: Mapping[str, bytes], restore_output: Path, project: MsbuildProject, + architecture: str, qualifier: str, template: str, variables: dict[str, object], + *, identity: dict[str, str] | None = None, config: dict[str, str], +) -> Node: + """Create one build from the project's exact signed bytes and TU predecessors.""" + + files = {path: (repository / path).read_bytes() for path in project.inputs} + dependencies = [] + for source in project.sources: + name = dependency_node_name( + repository, architecture, project.name, source, qualifier + ) + dependencies.append(discovery.node(name)) + try: + manifest = manifests[name] + except KeyError as error: + raise ValueError(f"missing dependency manifest: {name}") from error + files.update(dependency_inputs( + repository, restore_output, source, manifest, project.headers + )) + return factory.make( + template, f"build-{project.name}-{architecture}-{qualifier}", "slot", variables, + files=files, dependencies=tuple(dependencies), identity=identity, config=config, + ) + + +def _dependency_node( + repository: Path, toolchain: MsvcToolchain, factory: NodeFactory, restore: Node, + unit: tuple[str, Path, Path, tuple[Path, ...]], namespace: str, architecture: str, + platform: str, configuration: str | None, qualifier: str, +) -> Node: + project_name, project, source, headers = unit + name = dependency_node_name( + repository, architecture, project_name, source, qualifier + ) + restore_output = BuildPaths(repository).cas(restore.uid).output + files = _discovery_files(repository, project_name, project, source, headers) + variables = _compile_variables( + toolchain, project, source, restore_output, + configuration or ("Release" if project_name == "leak-probe" else "Debug"), + platform, + ) + return factory.make( + "source-dependencies.ps1", + name, + "slot", + variables, + files=files, + dependencies=(restore,), + config={"architecture": architecture, "source_namespace": namespace}, + ) + + +def dependency_discovery_slice( + repository: Path, toolchain: MsvcToolchain, *, + project_names: tuple[str, ...] | None = None, configuration: str | None = None, + restore_flavor: str = "", name_qualifier: str = "", jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + """Create cacheable per-TU MSVC ``/sourceDependencies`` nodes.""" + + root = repository.resolve(strict=True) + factory = recipe_factory(root, dict(toolchain.identity), tool_environment(toolchain)) + namespace = "\n".join( + _relative(root, path) + for path in sorted(path for path in (root / "src").rglob("*") if path.is_file()) + ) + projects = tuple( + unit for unit in _projects(root) if project_names is None or unit[0] in project_names + ) + def create() -> Graph: + nodes, targets = [], [] + for architecture in architectures: + platform = _platform(architecture) + restore = restore_node( + root, toolchain, factory, architecture, flavor=restore_flavor + ) + nodes.append(restore) + for unit in projects: + project_name = unit[0] + if project_name == "leak-probe" and architecture != "x64": + continue + node = _dependency_node( + root, toolchain, factory, restore, unit, namespace, architecture, + platform, configuration, name_qualifier, + ) + nodes.append(node) + targets.append(node.name) + return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) + + return create() + + +def _exact_identity(prefix: str, tool: ResolvedTool) -> dict[str, str]: + return {f"{prefix}.{key}": value for key, value in tool.identity} + + +def clang_dependency_discovery_slice( + repository: Path, toolchain: MsvcToolchain, *, + project_names: tuple[str, ...] | None = None, configuration: str, + restore_flavor: str = "", name_qualifier: str = "", jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + """Capture actual clang-cl commands and resolve their exact header graph.""" + + root = repository.resolve(strict=True) + environment = tool_environment(toolchain) + base_identity = dict(toolchain.identity) + clang = resolve_llvm(toolchain, "clang-cl") + scanner = resolve_llvm(toolchain, "clang-scan-deps") + clang_factory = recipe_factory( + root, + base_identity | _exact_identity("clang_cl", clang), + environment, + ) + scan_factory = recipe_factory(root, base_identity, environment) + namespace = "\n".join( + _relative(root, path) + for path in sorted(path for path in (root / "src").rglob("*") if path.is_file()) + ) + projects = tuple( + unit for unit in _projects(root) if project_names is None or unit[0] in project_names + ) + paths = BuildPaths(root) + + def create() -> Graph: + nodes: list[Node] = [] + targets: list[str] = [] + for architecture in architectures: + platform = _platform(architecture) + restore = restore_node( + root, toolchain, clang_factory, architecture, flavor=restore_flavor + ) + nodes.append(restore) + restore_output = paths.cas(restore.uid).output + for project_name, project, source, headers in projects: + if project_name == "leak-probe" and architecture != "x64": + continue + name = dependency_node_name( + root, architecture, project_name, source, name_qualifier + ) + files = _discovery_files(root, project_name, project, source, headers) + capture = clang_factory.make( + "clang-command.ps1", + name.replace("discover-dependencies-", "capture-clang-command-", 1), + "slot", + _compile_variables( + toolchain, + project, + source, + restore_output, + configuration, + platform, + ) | {"llvm_dir": str(toolchain.llvm_dir)}, + files=files, + dependencies=(restore,), + config={"architecture": architecture, "source_namespace": namespace}, + ) + command_file = paths.cas(capture.uid).output / "compile-command.json" + scan = python_action( + scan_factory, + name, + "core.clang_dependencies", + ( + "scan", + str(source), + str(command_file), + str(scanner.path), + str(clang.path), + ), + (capture,), + pool="slot", + environment=environment, + identity=_exact_identity("clang_scan_deps", scanner), + config={"architecture": architecture, "source_namespace": namespace}, + ) + nodes.extend((capture, scan)) + targets.append(scan.name) + return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) + + return create() + + +def analysis_discovery_slice( + repository: Path, toolchain: MsvcToolchain, jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + return dependency_discovery_slice( + repository, toolchain, jobs=jobs, architectures=architectures + ) + + +def load_dependency_manifests(repository: Path, discovery: Graph) -> dict[str, bytes]: + """Load manifests only from completed discovery nodes' exact CAS entries.""" + + paths, manifests = BuildPaths(repository.resolve(strict=True)), {} + for name in discovery.targets: + node = discovery.node(name) + cas = paths.cas(node.uid) + manifest = paths.require_confined( + cas.output / "dependencies.json", paths.cas_root + ) + if not cas.touch.is_file() or cas.touch.stat().st_size or not manifest.is_file(): + raise FileNotFoundError(f"dependency discovery is incomplete: {name}") + manifests[name] = manifest.read_bytes() + return manifests + + +def _raw( + repository: Path, toolchain: MsvcToolchain, factory: NodeFactory, discovery: Node, + restore_output: Path, unit: tuple[str, Path, Path, tuple[Path, ...]], backend: str, + dependencies: Mapping[str, bytes], + architecture: str, platform: str, +) -> Node: + project_name, project, source, _headers = unit + slug, template, extra = { + "msvc": ("msvc", "msvc-analyze.ps1", ("build/ObserverNativeAnalysis.ruleset",)), + "clang-tidy": ("tidy", "clang-tidy.ps1", (".clang-tidy",)), + }[backend] + files = _project_files(repository, project_name, project, source, extra) + files.update(dependencies) + variables = _compile_variables( + toolchain, project, source, restore_output, + "Release" if project_name == "leak-probe" else "Debug", + platform, + ) | {"project_name": project_name, "llvm_dir": str(toolchain.llvm_dir)} + return factory.make( + template, + f"analyze-{slug}-{architecture}-{project_name}-{_unit(repository, source)}", + "slot", + variables, + files=files, + dependencies=(discovery,), + config={"architecture": architecture, "backend": backend}, + ) + + +def analysis_slice( + repository: Path, toolchain: MsvcToolchain, *, discovery: Graph, + manifests: Mapping[str, bytes], jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + """Analyze compiler-discovered TU inputs, normalize, merge, and gate.""" + + root = repository.resolve(strict=True) + factory = recipe_factory(root, dict(toolchain.identity), tool_environment(toolchain)) + paths = BuildPaths(root) + + def output(node: Node) -> Path: + return paths.cas(node.uid).output + + nodes, targets, projects = list(discovery.nodes), [], _projects(root) + for architecture in architectures: + platform = _platform(architecture) + restore = discovery.node(f"restore-vcpkg-{architecture}") + normalized = [] + for unit in projects: + project_name, _project, source, headers = unit + if project_name == "leak-probe" and architecture != "x64": + continue + unit_name = _unit(root, source) + suffix = f"{architecture}-{project_name}-{unit_name}" + discovery_name = dependency_node_name(root, architecture, project_name, source) + discovered = discovery.node(discovery_name) + try: + manifest = manifests[discovery_name] + except KeyError as error: + raise ValueError(f"missing dependency manifest: {discovery_name}") from error + dependencies = dependency_inputs( + root, output(restore), source, manifest, headers + ) + raw_nodes = tuple( + (backend, _raw( + root, toolchain, factory, discovered, output(restore), unit, backend, + dependencies, architecture, platform, + )) + for backend in ("msvc", "clang-tidy") + ) + nodes.extend(raw for _backend, raw in raw_nodes) + for backend, raw in raw_nodes: + action = "normalize-msvc" if backend == "msvc" else "convert-tidy" + source_input = output(raw) / ( + f"{project_name}.sarif" if backend == "msvc" else "obj" + ) + automation = ( + f"{'msvc-analyze' if backend == 'msvc' else 'clang-tidy'}/" + f"{architecture}/{project_name}/{unit_name}/" + ) + arguments = ( + (action, str(source_input), automation) + if backend == "msvc" + else (action, str(root), str(source_input), automation) + ) + normal = python_action( + factory, + f"normalize-{'msvc' if backend == 'msvc' else 'tidy'}-{suffix}", + "core.sarif", + arguments, + (raw,), + pool="misc", + ) + nodes.append(normal) + normalized.append(normal) + merged = python_action( + factory, + f"merge-analysis-{architecture}", + "core.sarif", + ("merge", *(str(output(node) / "renpy.sarif") for node in normalized)), + tuple(normalized), + pool="misc", + results=(Result( + f"reports/sarif/{architecture}/analysis.sarif", + "sarif", + "application/sarif+json", + "analysis.sarif", + ),), + ) + gate = python_action( + factory, + f"analysis-{architecture}", + "core.sarif", + ("gate", str(output(merged) / "analysis.sarif")), + (merged,), + pool="misc", + ) + nodes.extend((merged, gate)) + targets.append(gate.name) + return Graph(tuple(nodes), tuple(targets), {"misc": jobs, "restore": 1, "slot": jobs}) diff --git a/build/graphs/audit.py b/build/graphs/audit.py new file mode 100644 index 0000000..09bdf35 --- /dev/null +++ b/build/graphs/audit.py @@ -0,0 +1,109 @@ +"""Fine-grained PE and BinSkim audit nodes over explicit release artifacts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from core.graph import Graph, Node, Result +from core.paths import BuildPaths +from graphs.common import BUILD_ROOT, canonical_artifact, extend_pools, python_action, recipe_factory, require_positive_integers, require_tool + + +_ARCHITECTURES = {"x86", "x64", "arm64"} +_MODULES = ("renpy", "rpgmaker", "zanzarah") + + +def audit_graph( + repository: Path, + upstream: Graph, + *, + architectures: tuple[str, ...] = ("x64",), + include_leak_probe: bool = False, + dumpbin: Path, + dumpbin_identity: Mapping[str, str], + binskim: Path, + binskim_identity: Mapping[str, str], + jobs: int = 2, +) -> Graph: + """Compose independent release-binary audits over a validated upstream graph.""" + + require_positive_integers((jobs,), "audit pool capacities must be positive integers") + root = repository.resolve(strict=True) + paths = BuildPaths(root) + if ( + not architectures + or len(architectures) != len(set(architectures)) + or any(item not in _ARCHITECTURES for item in architectures) + ): + raise ValueError("audit architectures must be a unique non-empty supported set") + dumpbin = require_tool(dumpbin, "dumpbin") + binskim = require_tool(binskim, "BinSkim") + dump_factory = recipe_factory(root, dict(dumpbin_identity) | {"path": str(dumpbin)}) + python_factory = recipe_factory(BUILD_ROOT, {}) + audit_nodes: list[Node] = [] + targets: list[str] = [] + modules = (("leak-probe",) if include_leak_probe else ()) + _MODULES + + for architecture in sorted(architectures): + for module in modules: + producer, binary = canonical_artifact( + paths, + upstream, + f"build-{module}-{architecture}-release", + "leak-probe.exe" if module == "leak-probe" else f"{module}.so", + ) + + dump_nodes = [] + for mode in ("headers", "dependents", "exports"): + current = dump_factory.make( + "argv.json", + f"audit-dumpbin-{mode}-{architecture}-{module}", + "slot", + {"argv": (str(dumpbin), f"/{mode}", str(binary))}, + files={}, + dependencies=(producer,), + config={"action": mode, "architecture": architecture, "module": module}, + ) + dump_nodes.append(current) + pe_gate = python_action( + python_factory, + f"audit-pe-{architecture}-{module}", + "core.binary_audit", + ( + "pe", architecture, + *(str(paths.cas(node.uid).log) for node in dump_nodes), + ), + tuple(dump_nodes), + pool="slot", + ) + binskim_run = python_action( + python_factory, + f"audit-binskim-run-{architecture}-{module}", + "core.binary_audit", + ("run-binskim", str(binskim), str(binary)), + (producer,), + pool="slot", + identity=dict(binskim_identity) | {"path": str(binskim)}, + config={"action": "run-binskim", "architecture": architecture, "module": module}, + results=(Result( + f"reports/sarif/{architecture}/binskim-{module}.sarif", + "sarif", + "application/sarif+json", + "binskim.sarif", + ),), + ) + report = paths.cas(binskim_run.uid).output / "binskim.sarif" + binskim_gate = python_action( + python_factory, + f"audit-binskim-{architecture}-{module}", + "core.binary_audit", + ("binskim", str(report)), + (binskim_run,), + pool="slot", + ) + audit_nodes.extend((*dump_nodes, pe_gate, binskim_run, binskim_gate)) + targets.extend((pe_gate.name, binskim_gate.name)) + + pools = extend_pools(upstream, {"slot": jobs}) + return Graph(upstream.nodes + tuple(audit_nodes), tuple(targets), pools) diff --git a/build/graphs/common.py b/build/graphs/common.py new file mode 100644 index 0000000..78c4f3f --- /dev/null +++ b/build/graphs/common.py @@ -0,0 +1,166 @@ +"""Shared recipe plumbing for repository build graphs.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import hashlib +import os +from pathlib import Path +import sys + +from core.graph import Graph, Node, Result +from core.node import NodeFactory +from core.paths import BuildPaths +from core.render import TemplateRenderer + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +PROJECTS = ("renpy", "rpgmaker", "zanzarah", "tests") +BINARIES = {**{name: f"{name}.so" for name in PROJECTS[:-1]}, "tests": "tests.exe"} +PLATFORMS = {"x86": "Win32", "x64": "x64", "arm64": "ARM64"} +_PROJECT_PREFIX = "$(RepositoryRoot)" +COMMON_PROJECT_INPUTS = ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", +) + + +def recipe_factory( + cwd: Path, + identity: dict[str, str], + environment: tuple[tuple[str, str], ...] = (), +) -> NodeFactory: + return NodeFactory(TemplateRenderer(BUILD_ROOT / "templates"), cwd, identity, environment) + + +def require_positive_integers(values: Iterable[object], message: str) -> None: + if any(isinstance(value, bool) or not isinstance(value, int) or value <= 0 for value in values): + raise ValueError(message) + + +def require_tool(path: Path, name: str) -> Path: + resolved = path.resolve(strict=True) + if not resolved.is_file(): + raise FileNotFoundError(f"{name} is not a file: {resolved}") + return resolved + + +def prefixed_identity(prefix: str, path: Path, values: Mapping[str, str]) -> dict[str, str]: + return {f"{prefix}.{key}": value for key, value in values.items()} | { + f"{prefix}.path": str(path) + } + + +def extend_pools(upstream: Graph, additions: Mapping[str, int]) -> dict[str, int]: + pools = dict(upstream.pools) + for name, capacity in additions.items(): + if name in pools and pools[name] != capacity: + raise ValueError(f"conflicting pool capacity for {name}") + pools[name] = capacity + return pools + + +def canonical_artifact( + paths: BuildPaths, upstream: Graph, producer_name: str, relative_path: str +) -> tuple[Node, Path]: + producer = upstream.node(producer_name) + return producer, paths.cas(producer.uid).output / relative_path + + +def require_ancestor(upstream: Graph, producer: Node, ancestor: str, message: str) -> None: + pending, seen = list(producer.inputs), set() + while pending: + name = pending.pop() + if name == ancestor: + return + if name not in seen: + seen.add(name) + pending.extend(upstream.node(name).inputs) + raise ValueError(message) + + +def required_audit_gates(upstream: Graph, producer: Node, architecture: str, module: str) -> tuple[Node, Node]: + try: + gates = tuple(upstream.node(f"audit-{kind}-{architecture}-{module}") for kind in ("pe", "binskim")) + except ValueError as error: + raise ValueError(f"missing audit gates for {architecture}/{module}") from error + for gate in gates: + require_ancestor( + upstream, gate, producer.name, + f"audit gates do not consume producer for {architecture}/{module}", + ) + return gates + + +def project_path(repository: Path, value: str, project: Path) -> str: + if not value.startswith(_PROJECT_PREFIX): + raise ValueError(f"unsupported project input in {project}: {value}") + path = repository / value.removeprefix(_PROJECT_PREFIX).replace("\\", "/") + return path.resolve(strict=True).relative_to(repository).as_posix() + + +def tool_environment(toolchain: object, *, prepend_path: Path | None = None, + extra: tuple[tuple[str, str], ...] = ()) -> tuple[tuple[str, str], ...]: + values = {key.casefold(): (key, value) for key, value in toolchain.environment} + if hasattr(toolchain, "vcpkg_root"): + values["vcpkg_root"] = ("VCPKG_ROOT", str(toolchain.vcpkg_root)) + if prepend_path is not None: + current = values.get("path", ("PATH", ""))[1] + values["path"] = ("PATH", str(prepend_path) + (os.pathsep + current if current else "")) + values.update((key.casefold(), (key, value)) for key, value in extra) + return tuple(values.values()) + + +def _restore_identity(toolchain: object, vcpkg: Path) -> dict[str, str]: + identity = { + "pwsh": str(toolchain.pwsh), + "vcpkg": str(vcpkg), + "vcpkg_root": str(toolchain.vcpkg_root), + } + for name, path in (("pwsh", Path(toolchain.pwsh)), ("vcpkg", vcpkg)): + if path.is_file(): + with path.open("rb") as stream: + identity[f"{name}_sha256"] = hashlib.file_digest(stream, "sha256").hexdigest() + return identity + + +def restore_node(repository: Path, toolchain: object, factory: NodeFactory, architecture: str, + *, flavor: str = "") -> Node: + qualifier = f"-{flavor}" if flavor else "" + triplet = f"observer-{architecture}-windows-static{qualifier}" + inputs = ("vcpkg.json", f"build/vcpkg/triplets/{triplet}.cmake") + vcpkg = Path(toolchain.vcpkg_root) / "vcpkg.exe" + return factory.make( + "vcpkg.ps1", f"restore-vcpkg{qualifier}-{architecture}", "restore", { + "pwsh": str(toolchain.pwsh), + "vcpkg": str(vcpkg), + "repository": str(repository), + "triplet": triplet, + }, + files={path: (repository / path).read_bytes() for path in inputs}, + config={"action": "restore", "architecture": architecture, "triplet": triplet}, + identity=_restore_identity(toolchain, vcpkg), + environment=tuple(sorted((key.upper(), value) for key, value in tool_environment(toolchain))), + cwd=repository, + ) + + +def python_action(factory: NodeFactory, name: str, module: str, arguments: tuple[str, ...], + dependencies: tuple[Node, ...], *, pool: str, + environment: tuple[tuple[str, str], ...] = (), files: Mapping[str, bytes] | None = None, + identity: Mapping[str, str] | None = None, + config: Mapping[str, str] | None = None, + results: tuple[Result, ...] = ()) -> Node: + executable = str(Path(sys.executable).resolve()) + source = BUILD_ROOT.joinpath(*module.split(".")).with_suffix(".py") + return factory.make( + "argv.json", name, pool, {"argv": (executable, "-m", module) + arguments}, + files={source.relative_to(BUILD_ROOT.parent).as_posix(): source.read_bytes()} | dict(files or {}), + dependencies=dependencies, + results=results, + identity=dict(identity or {}) | {"python": sys.version, "python_executable": executable}, + config={"action": arguments[0], "platform": "windows"} | dict(config or {}), + environment=environment, + cwd=BUILD_ROOT, + ) diff --git a/build/graphs/coverage.py b/build/graphs/coverage.py new file mode 100644 index 0000000..8e25a66 --- /dev/null +++ b/build/graphs/coverage.py @@ -0,0 +1,299 @@ +"""Fine-grained LLVM source coverage over explicit instrumented build artifacts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from core.graph import Graph, Node, Result +from core.paths import BuildPaths +from core.quality_tools import resolve_llvm, resolve_tool +from core.toolchain import MsvcToolchain +from graphs.common import ( + BINARIES, + BUILD_ROOT, + canonical_artifact, + extend_pools, + prefixed_identity, + python_action, + recipe_factory, + require_ancestor, + require_positive_integers, + require_tool, + tool_environment, +) +from graphs.instrumented import InstrumentedVariant, instrumented_build_slice, instrumented_dependency_discovery_slice + + +_ARCHITECTURES = {"x86", "x64", "arm64"} +_IGNORED_SOURCES = ( + r"([\\/]src[\\/](tests|fuzz)[\\/])|([\\/]vcpkg_installed[\\/])|" + r"([\\/]Microsoft Visual Studio[\\/])|([\\/]Windows Kits[\\/])" +) + + +def _builds( + paths: BuildPaths, + upstream: Graph, + architectures: tuple[str, ...], +) -> dict[str, dict[str, tuple[Node, Path]]]: + if ( + not architectures + or len(architectures) != len(set(architectures)) + or any(item not in _ARCHITECTURES for item in architectures) + ): + raise ValueError("coverage architectures must be a unique non-empty supported set") + selected = {} + for architecture in architectures: + group = {} + for name, filename in BINARIES.items(): + producer, path = canonical_artifact( + paths, upstream, f"build-{name}-{architecture}-coverage", filename + ) + group[name] = (producer, path) + restore = f"restore-vcpkg-{architecture}" + for producer, _path in group.values(): + require_ancestor( + upstream, producer, restore, + f"coverage build requires {restore} as a restore ancestor", + ) + selected[architecture] = group + return selected + + +def coverage_artifact_graph( + repository: Path, + upstream: Graph, + *, + architectures: tuple[str, ...], + pwsh: Path, + pwsh_identity: Mapping[str, str], + llvm_profdata: Path, + llvm_profdata_identity: Mapping[str, str], + llvm_cov: Path, + llvm_cov_identity: Mapping[str, str], + environment: tuple[tuple[str, str], ...] = (), + test_shards: int = 4, + jobs: int = 4, + report_jobs: int = 2, + corpus: Path | None = None, + run_nonce: str = "", +) -> Graph: + """Compose shard, merge, report, and immutable 100% gates per runnable arch.""" + + require_positive_integers( + (test_shards, jobs, report_jobs), + "coverage counts and pool capacities must be positive integers", + ) + corpus_path = None + if corpus is not None: + if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce: + raise ValueError("corpus run nonce must be a non-empty string without NUL") + corpus_path = Path(corpus).resolve(strict=True) + if not corpus_path.is_dir(): + raise NotADirectoryError(corpus_path) + root = repository.resolve(strict=True) + paths = BuildPaths(root) + pwsh = require_tool(pwsh, "PowerShell") + llvm_profdata = require_tool(llvm_profdata, "llvm-profdata") + llvm_cov = require_tool(llvm_cov, "llvm-cov") + selected = _builds(paths, upstream, architectures) + pwsh_id = prefixed_identity("pwsh", pwsh, pwsh_identity) + shard_factory = recipe_factory(root, pwsh_id, environment) + merge_factory = recipe_factory( + root, + pwsh_id | prefixed_identity("llvm_profdata", llvm_profdata, llvm_profdata_identity), + environment, + ) + report_factory = recipe_factory( + root, + pwsh_id | prefixed_identity("llvm_cov", llvm_cov, llvm_cov_identity), + environment, + ) + gate_factory = recipe_factory(BUILD_ROOT, {}) + nodes: list[Node] = [] + targets: list[str] = [] + + def output(node: Node, filename: str = "") -> Path: + return paths.cas(node.uid).output / filename + + for architecture, group in sorted(selected.items()): + builds = tuple(group[name][0] for name in BINARIES) + copies = tuple( + {"name": BINARIES[name], "source": str(group[name][1])} for name in BINARIES + ) + shards = tuple( + shard_factory.make( + "coverage-test.ps1", + f"coverage-test-{architecture}-{index}", + "coverage-shard", + { + "pwsh": str(pwsh), "artifacts": copies, + "shard_count": test_shards, "shard_index": index, + }, + files={}, dependencies=builds, + results=(Result( + f"reports/coverage/cpp/{architecture}/tests/shard-{index}.xml", + "test", + "application/xml", + "tests.xml", + ),), + config={ + "action": "coverage-test", "architecture": architecture, + "shard_count": str(test_shards), "shard_index": str(index), + }, + ) + for index in range(test_shards) + ) + corpus_shards: tuple[Node, ...] = () + if corpus_path is not None: + corpus_environment = tuple( + pair for pair in environment + if pair[0].casefold() != "observer_test_corpus" + ) + (("OBSERVER_TEST_CORPUS", str(corpus_path)),) + corpus_shards = tuple( + shard_factory.make( + "coverage-corpus-test.ps1", + f"coverage-corpus-{architecture}-{index}", + "coverage-shard", + { + "pwsh": str(pwsh), "artifacts": copies, + "shard_count": test_shards, "shard_index": index, + }, + files={}, dependencies=builds, + results=(Result( + f"reports/coverage/cpp/{architecture}/corpus/shard-{index}.xml", + "test", + "application/xml", + "tests.xml", + ),), + config={ + "action": "coverage-corpus-test", + "architecture": architecture, + "shard_count": str(test_shards), + "shard_index": str(index), + "corpus": str(corpus_path), + "run_nonce": run_nonce, + }, + environment=corpus_environment, + ) + for index in range(test_shards) + ) + profile_shards = shards + corpus_shards + merge = merge_factory.make( + "coverage-merge.ps1", + f"coverage-merge-{architecture}", + "coverage-merge", + { + "pwsh": str(pwsh), "llvm_profdata": str(llvm_profdata), + "profile_directories": tuple(str(output(shard)) for shard in profile_shards), + }, + files={}, dependencies=profile_shards, + config={"action": "coverage-merge", "architecture": architecture}, + ) + profile = output(merge, "coverage.profdata") + reports = [] + for kind, summary_only in (("json", True), ("lcov", False)): + report = report_factory.make( + "coverage-report.ps1", + f"coverage-{kind}-{architecture}", + "coverage-report", + { + "pwsh": str(pwsh), "llvm_cov": str(llvm_cov), + "test_executable": str(group["tests"][1]), + "profile": str(profile), "ignore_regex": _IGNORED_SOURCES, + "objects": tuple(str(group[name][1]) for name in BINARIES if name != "tests"), + "summary_only": summary_only, "report_name": f"coverage.{kind}", + }, + files={}, dependencies=(merge, *builds), + results=(Result( + f"reports/coverage/cpp/{architecture}/coverage.{kind}", + "coverage", + "application/json" if kind == "json" else "text/plain", + f"coverage.{kind}", + ),), + config={"action": f"coverage-{kind}", "architecture": architecture}, + ) + reports.append(report) + gate = python_action( + gate_factory, + f"coverage-{architecture}", + "core.cpp_coverage", + ("gate", str(output(reports[0], "coverage.json"))), + tuple(reports), + pool="coverage-gate", + ) + nodes.extend((*profile_shards, merge, *reports, gate)) + targets.append(gate.name) + + pools = extend_pools( + upstream, + { + "coverage-shard": jobs, + "coverage-merge": len(selected), + "coverage-report": report_jobs, + "coverage-gate": jobs, + }, + ) + return Graph(upstream.nodes + tuple(nodes), tuple(targets), pools) + + +def coverage_dependency_discovery_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), +) -> Graph: + """Discover exact inputs for every requested Coverage build.""" + + variants = tuple(InstrumentedVariant("coverage", item) for item in architectures) + return instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants, jobs=jobs + ) + + +def coverage_graph( + repository: Path, + toolchain: MsvcToolchain, + *, + discovery: Graph | None, + manifests: Mapping[str, bytes], + jobs: int = 4, + architectures: tuple[str, ...] = ("x64",), + test_shards: int = 4, + report_jobs: int = 2, + corpus: Path | None = None, + run_nonce: str = "", +) -> Graph: + """Build instrumented C++ artifacts, run shards, report, and gate coverage.""" + + variants = tuple(InstrumentedVariant("coverage", item) for item in architectures) + upstream = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=variants, + jobs=jobs, + ) + pwsh = resolve_tool(toolchain.pwsh, "PowerShell") + profdata = resolve_llvm(toolchain, "llvm-profdata") + cov = resolve_llvm(toolchain, "llvm-cov") + return coverage_artifact_graph( + repository, + upstream, + architectures=architectures, + pwsh=pwsh.path, + pwsh_identity=dict(pwsh.identity), + llvm_profdata=profdata.path, + llvm_profdata_identity=dict(profdata.identity), + llvm_cov=cov.path, + llvm_cov_identity=dict(cov.identity), + environment=tool_environment(toolchain), + test_shards=test_shards, + jobs=jobs, + report_jobs=report_jobs, + corpus=corpus, + run_nonce=run_nonce, + ) diff --git a/build/graphs/fuzz.py b/build/graphs/fuzz.py new file mode 100644 index 0000000..f7c0ee9 --- /dev/null +++ b/build/graphs/fuzz.py @@ -0,0 +1,287 @@ +"""Independent build, seed-replay, and bounded-run branches for every fuzzer.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +import re + +from core.graph import Graph, Node, Result +from core.paths import BuildPaths +from core.toolchain import MsvcToolchain +from graphs.analysis import ( + clang_dependency_discovery_slice, + dependency_inputs, + dependency_node_name, + project_inventory, +) +from graphs.common import recipe_factory, tool_environment + + +_BUILD_ROOT = Path(__file__).resolve().parents[1] +_TARGETS = {"pickle": 262144, "renpy": 1048576, "rpgmaker": 1048576, "zanzarah": 1048576} +FUZZ_TARGETS = tuple(_TARGETS) +_COMMON = ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", + "build/ObserverFuzz.props", +) +_ASAN_OPTIONS = "halt_on_error=1:alloc_dealloc_mismatch=1" +_UID = re.compile(r"[0-9a-f]{32}") + + +@dataclass(frozen=True, slots=True) +class FuzzCorpusArtifact: + """Reference one successfully published timed-run corpus by canonical CAS UID.""" + + target: str + producer_uid: str + + def __post_init__(self) -> None: + if self.target not in _TARGETS: + raise ValueError(f"unsupported fuzz corpus target: {self.target}") + if not isinstance(self.producer_uid, str) or not _UID.fullmatch(self.producer_uid): + raise ValueError("fuzz corpus producer UID must be a canonical MD5") + + +def fuzz_corpus_artifacts(graph: Graph) -> tuple[FuzzCorpusArtifact, ...]: + return tuple( + FuzzCorpusArtifact(target, graph.node(f"run-fuzz-x64-{target}").uid) + for target in FUZZ_TARGETS if f"fuzz-x64-{target}" in graph.targets + ) + + +def _selected_targets(targets: tuple[str, ...]) -> tuple[str, ...]: + if not targets: + raise ValueError("fuzz targets must not be empty") + selected = set() + for target in targets: + if target not in _TARGETS: + raise ValueError(f"unsupported fuzz target: {target}") + if target in selected: + raise ValueError(f"duplicate fuzz target: {target}") + selected.add(target) + return targets + + +def _runtime(toolchain: MsvcToolchain) -> Path: + candidates = sorted(toolchain.llvm_dir.glob("lib/clang/*/lib/windows"), reverse=True) + if not candidates or not candidates[0].is_dir(): + raise FileNotFoundError(f"LLVM sanitizer runtimes not found below {toolchain.llvm_dir}") + return candidates[0].resolve() + + +def fuzz_dependency_discovery_slice( + repository: Path, toolchain: MsvcToolchain, *, jobs: int = 2, + targets: tuple[str, ...] = FUZZ_TARGETS, +) -> Graph: + """Discover Fuzz-configuration dependencies against the ASan triplet.""" + + return clang_dependency_discovery_slice( + repository, toolchain, + project_names=tuple(f"fuzz-{target}" for target in _selected_targets(targets)), + configuration="Fuzz", restore_flavor="asan", name_qualifier="fuzz", jobs=jobs, + ) + + +def _project_files( + repository: Path, target: str, restore_output: Path, discovery: Graph, + manifests: Mapping[str, bytes], +) -> tuple[dict[str, bytes], tuple[Node, ...]]: + try: + project = project_inventory( + repository, (f"fuzz-{target}",), include_link_inputs=False + )[0] + except ValueError as error: + message = str(error).replace( + "unsupported project input", "unsupported ClCompile path", 1 + ) + raise ValueError(message) from error + paths = [repository / relative for relative in _COMMON] + [project.path] + files = {} + dependencies = [] + for source in project.sources: + name = dependency_node_name(repository, "x64", f"fuzz-{target}", source, "fuzz") + dependencies.append(discovery.node(name)) + try: + manifest = manifests[name] + except KeyError as error: + raise ValueError(f"missing dependency manifest: {name}") from error + files.update(dependency_inputs( + repository, restore_output, source, manifest, project.headers + )) + relative = (path.resolve(strict=True).relative_to(repository).as_posix() for path in paths) + files.update({name: (repository / name).read_bytes() for name in dict.fromkeys(relative)}) + return files, tuple(dependencies) + + +def _seed_files(repository: Path, target: str) -> tuple[Path, dict[str, bytes]]: + directory = repository / "src/fuzz/corpus" / target + seeds = tuple(sorted(path for path in directory.iterdir() if path.is_file())) + if not seeds: + raise FileNotFoundError(f"no checked-in fuzzer seeds for {target}") + return directory, { + path.relative_to(repository).as_posix(): path.read_bytes() for path in seeds + } + + +def _prior_corpora( + repository: Path, artifacts: tuple[FuzzCorpusArtifact, ...] +) -> dict[str, tuple[FuzzCorpusArtifact, Path, dict[str, bytes]]]: + indexed = {} + for artifact in artifacts: + if artifact.target in indexed: + raise ValueError(f"duplicate prior corpus: {artifact.target}") + indexed[artifact.target] = artifact + + paths, result = BuildPaths(repository), {} + for target, artifact in indexed.items(): + node_name = f"run-fuzz-x64-{target}" + cas = paths.cas(artifact.producer_uid) + corpus = paths.require_confined(cas.output / "corpus", paths.cas_root) + if not ( + cas.entry.is_dir() + and cas.output.is_dir() + and cas.log.is_file() + and cas.touch.is_file() + and cas.touch.stat().st_size == 0 + and corpus.is_dir() + ): + raise FileNotFoundError(f"prior fuzz corpus is not published: {target}") + files = {} + for path in sorted(corpus.iterdir()): + confined = paths.require_confined(path, paths.cas_root) + if not confined.is_file(): + raise ValueError(f"prior fuzz corpus contains a non-file: {confined}") + files[f"prior/{target}/{confined.name}"] = confined.read_bytes() + if not files: + raise ValueError(f"prior fuzz corpus is empty: {target}") + result[target] = (artifact, corpus, files) + return result + + +def fuzz_graph( + repository: Path, toolchain: MsvcToolchain, *, discovery: Graph | None, + manifests: Mapping[str, bytes], run_nonce: str, seconds: int = 60, + jobs: int = 2, fuzz_jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), + prior_corpora: tuple[FuzzCorpusArtifact, ...] = (), + targets: tuple[str, ...] = FUZZ_TARGETS, +) -> Graph: + """Return the selected demand-independent x64 libFuzzer branches.""" + + if architectures != ("x64",): + raise ValueError("libFuzzer MSBuild contract is x64-only") + if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce: + raise ValueError("run nonce must be a non-empty string without NUL") + if isinstance(seconds, bool) or seconds <= 0: + raise ValueError("fuzz seconds must be positive") + selected_targets = _selected_targets(targets) + if discovery is None: + raise ValueError("fuzz dependency discovery is required") + root = repository.resolve(strict=True) + prior = _prior_corpora(root, prior_corpora) + runtime = _runtime(toolchain) + paths = BuildPaths(root) + identity = dict(toolchain.identity) | {"llvm_runtime": str(runtime)} + factory = recipe_factory(root, identity, tool_environment(toolchain)) + + restore = discovery.node("restore-vcpkg-asan-x64") + nodes, targets = list(discovery.nodes), [] + restore_output = paths.cas(restore.uid).output + + for target in selected_targets: + max_length = _TARGETS[target] + executable_name = f"fuzz-{target}.exe" + files, dependencies = _project_files( + root, target, restore_output, discovery, manifests + ) + build = factory.make( + "fuzz-build.ps1", + f"build-fuzz-x64-{target}", + "build", + { + "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), + "project": str(root / f"build/projects/fuzz-{target}.vcxproj"), + "target": "Build", "configuration": "Fuzz", "platform": "x64", + "vcpkg_root": str(toolchain.vcpkg_root), "vcpkg_installed": str(restore_output), + "llvm_dir": str(toolchain.llvm_dir), "llvm_runtime": str(runtime), + "executable_name": executable_name, + }, + files=files, dependencies=dependencies, + config={"action": "build", "architecture": "x64", "target": target}, + ) + seed_dir, seed_files = _seed_files(root, target) + fuzzer = paths.cas(build.uid).output / executable_name + replay = factory.make( + "fuzz-replay.ps1", + f"replay-fuzz-x64-{target}", + "fuzz", + { + "pwsh": str(toolchain.pwsh), "fuzzer": str(fuzzer), + "seed_dir": str(seed_dir), "max_length": max_length, + }, + files=seed_files, dependencies=(build,), + config={"action": "replay", "target": target, "max_length": str(max_length), + "asan_options": _ASAN_OPTIONS}, + environment=tool_environment( + toolchain, + prepend_path=runtime, + extra=(("ASAN_OPTIONS", _ASAN_OPTIONS),), + ), + ) + previous = prior.get(target) + prior_uid, prior_path, prior_files = ( + ("", "", {}) + if previous is None + else (previous[0].producer_uid, str(previous[1]), previous[2]) + ) + run = factory.make( + "fuzz-run.ps1", + f"run-fuzz-x64-{target}", + "fuzz", + { + "pwsh": str(toolchain.pwsh), "fuzzer": str(fuzzer), "seed_dir": str(seed_dir), + "max_length": max_length, "seconds": seconds, "prior_corpus": prior_path, + }, + files=seed_files | prior_files, dependencies=(replay,), + results=( + Result( + f"reports/fuzz/x64/{target}/status.txt", + "fuzz", "text/plain", "status.txt", + ), + Result( + f"reports/fuzz/x64/{target}/corpus", + "corpus", "application/octet-stream", "corpus", + ), + Result( + f"reports/fuzz/x64/{target}/artifacts", + "evidence", "application/octet-stream", "artifacts", + ), + ), + config={"action": "fuzz", "target": target, "max_length": str(max_length), + "seconds": str(seconds), "run_nonce": run_nonce, + "asan_options": _ASAN_OPTIONS, "prior_corpus_uid": prior_uid}, + environment=tool_environment( + toolchain, + prepend_path=runtime, + extra=(("ASAN_OPTIONS", _ASAN_OPTIONS),), + ), + ) + gate = factory.make( + "fuzz-gate.ps1", + f"fuzz-x64-{target}", + "fuzz", + {"pwsh": str(toolchain.pwsh), + "status": str(paths.cas(run.uid).output / "status.txt")}, + files={}, dependencies=(run,), + config={"action": "fuzz-gate", "target": target}, + ) + nodes.extend((build, replay, run, gate)) + targets.append(gate.name) + return Graph( + tuple(nodes), tuple(targets), + {"build": jobs, "fuzz": fuzz_jobs, "restore": 1, "slot": jobs}, + ) diff --git a/build/graphs/instrumented.py b/build/graphs/instrumented.py new file mode 100644 index 0000000..e3bf444 --- /dev/null +++ b/build/graphs/instrumented.py @@ -0,0 +1,186 @@ +"""Staged MSBuild producers for Coverage, ASan, and UBSan graphs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path + +from core.graph import Graph, GraphError, merge_graphs +from core.paths import BuildPaths +from core.quality_tools import UBSAN_LIBRARIES, resolve_llvm +from core.toolchain import MsvcToolchain +from graphs.analysis import ( + clang_dependency_discovery_slice, + dependency_discovery_slice, + project_build, + project_inventory, +) +from graphs.common import ( + PLATFORMS, + PROJECTS, + recipe_factory, + require_positive_integers, + tool_environment, +) + + +_CONFIGURATIONS = {"coverage": "Coverage", "asan": "ASan", "ubsan": "UBSan"} + + +@dataclass(frozen=True, slots=True) +class InstrumentedVariant: + kind: str + architecture: str + llvm_runtime: Path | None = None + runtime_identity: Mapping[str, str] = field(default_factory=dict) + + @property + def configuration(self) -> str: + return _CONFIGURATIONS.get(self.kind, self.kind) + + +def _variants( + variants: tuple[InstrumentedVariant, ...], jobs: int +) -> tuple[InstrumentedVariant, ...]: + require_positive_integers((jobs,), "jobs must be a positive integer") + if not variants: + raise ValueError("at least one instrumented variant is required") + keys = [(item.kind, item.architecture) for item in variants] + if len(keys) != len(set(keys)): + raise ValueError("duplicate instrumented variant") + for item in variants: + supported = ( + item.kind == "coverage" and item.architecture in PLATFORMS + or item.kind == "asan" and item.architecture in {"x86", "x64"} + or item.kind == "ubsan" and item.architecture == "x64" + ) + if not supported: + raise ValueError(f"unsupported instrumented variant: {(item.kind, item.architecture)}") + if item.kind == "ubsan": + runtime = item.llvm_runtime.resolve(strict=True) if item.llvm_runtime else None + if runtime is None or not runtime.is_dir() or not item.runtime_identity: + raise ValueError("UBSan runtime directory and exact identity are required") + for name in UBSAN_LIBRARIES: + if not (runtime / name).is_file(): + raise FileNotFoundError(f"UBSan runtime library not found: {runtime / name}") + elif item.llvm_runtime is not None or item.runtime_identity: + raise ValueError(f"LLVM runtime is invalid for {item.kind} variant") + return variants + + +def instrumented_dependency_discovery_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + variants: tuple[InstrumentedVariant, ...], + jobs: int = 2, +) -> Graph: + """Discover compiler inputs per instrumented configuration and TU.""" + + selected = _variants(variants, jobs) + graphs = tuple( + ( + clang_dependency_discovery_slice( + repository, + toolchain, + project_names=PROJECTS, + configuration=item.configuration, + name_qualifier=item.kind, + jobs=jobs, + architectures=(item.architecture,), + ) + if item.kind in {"coverage", "ubsan"} + else dependency_discovery_slice( + repository, + toolchain, + project_names=PROJECTS, + configuration=item.configuration, + restore_flavor="asan", + name_qualifier=item.kind, + jobs=jobs, + architectures=(item.architecture,), + ) + ) + for item in selected + ) + try: + return merge_graphs(*graphs) + except GraphError as error: + name = str(error).rsplit(": ", 1)[-1] + raise ValueError(f"conflicting canonical node: {name}") from error + + +def instrumented_build_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + discovery: Graph | None, + manifests: Mapping[str, bytes], + variants: tuple[InstrumentedVariant, ...], + jobs: int = 2, +) -> Graph: + """Build independently cacheable modules/tests from completed discovery.""" + + selected = _variants(variants, jobs) + if discovery is None: + raise ValueError("instrumented dependency discovery is required") + if discovery.pools.get("slot") != jobs: + raise ValueError("conflicting slot pool capacity") + root = repository.resolve(strict=True) + factory = recipe_factory( + root, + dict(toolchain.identity), + tool_environment(toolchain), + ) + paths = BuildPaths(root) + clang_identity: dict[str, str] = {} + if any(item.kind in {"coverage", "ubsan"} for item in selected): + clang = resolve_llvm(toolchain, "clang-cl") + clang_identity = { + f"clang_cl.{key}": value for key, value in clang.identity + } + builds = [] + projects = project_inventory(root, PROJECTS) + for variant in selected: + restore_name = ( + f"restore-vcpkg-asan-{variant.architecture}" + if variant.kind == "asan" + else f"restore-vcpkg-{variant.architecture}" + ) + restore = discovery.node(restore_name) + restore_output = paths.cas(restore.uid).output + runtime = variant.llvm_runtime.resolve(strict=True) if variant.llvm_runtime else None + identity = dict(toolchain.identity) + if variant.kind in {"coverage", "ubsan"}: + identity.update(clang_identity) + if runtime is not None: + identity.update({ + f"llvm_runtime.{key}": value + for key, value in variant.runtime_identity.items() + }) + identity["llvm_runtime.path"] = str(runtime) + for project in projects: + build = project_build( + root, factory, discovery, manifests, restore_output, project, + variant.architecture, variant.kind, "instrumented-build.ps1", { + "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), + "project": str(project.path), "target": "Build", + "configuration": variant.configuration, + "platform": PLATFORMS[variant.architecture], + "vcpkg_root": str(toolchain.vcpkg_root), + "vcpkg_installed": str(restore_output), + "llvm_dir": str(toolchain.llvm_dir) + if variant.kind in {"coverage", "ubsan"} else "", + "llvm_runtime": str(runtime) if runtime else "", + }, identity=identity, config={ + "action": "build", "kind": variant.kind, + "architecture": variant.architecture, "project": project.name, + }, + ) + builds.append(build) + return Graph( + discovery.nodes + tuple(builds), + tuple(build.name for build in builds), + discovery.pools, + ) diff --git a/build/graphs/leak.py b/build/graphs/leak.py new file mode 100644 index 0000000..f4b9989 --- /dev/null +++ b/build/graphs/leak.py @@ -0,0 +1,135 @@ +"""Independent UMDH leak scenarios composed over x64 Release binaries.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from core.graph import Graph, Node, Result +from core.paths import BuildPaths +from graphs.common import ( + BUILD_ROOT, canonical_artifact, extend_pools, python_action, recipe_factory, required_audit_gates, + require_positive_integers, require_tool, +) + + +LEAK_MODES = ("operations", "lifecycle") +LEAK_SCENARIOS = ("small-success", "malformed", "cancellation", "read-failure", "write-failure", "large-metadata", "sparse-metadata") +_BINARIES = {"leak-probe": "leak-probe.exe", "renpy": "renpy.so", "rpgmaker": "rpgmaker.so", "zanzarah": "zanzarah.so"} + + +def leak_graph( + repository: Path, + upstream: Graph, + *, + umdh: Path, + umdh_identity: Mapping[str, str], + run_nonce: str, + warmup: int = 8, + iterations: int = 100, + windows: int = 3, + tolerance_bytes: int = 0, + jobs: int = 4, +) -> Graph: + """Add one demand target per leak mode/scenario without a monolithic gate.""" + + capacities = (jobs,) + counts = (warmup, iterations, windows) + require_positive_integers(capacities, "leak pool capacities must be positive integers") + require_positive_integers(counts, "leak measurement counts must be positive integers") + if windows < 3: + raise ValueError("leak measurement requires at least three windows") + if isinstance(tolerance_bytes, bool) or not isinstance(tolerance_bytes, int) or tolerance_bytes < 0: + raise ValueError("leak tolerance must be a non-negative integer") + if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce: + raise ValueError("run nonce must be a non-empty string without NUL") + + root = repository.resolve(strict=True) + paths = BuildPaths(root) + umdh = require_tool(umdh, "UMDH") + selected = { + name: canonical_artifact( + paths, upstream, f"build-{name}-x64-release", filename + ) + for name, filename in _BINARIES.items() + } + factory = recipe_factory(BUILD_ROOT, {}) + umdh_signature = {f"umdh.{key}": value for key, value in umdh_identity.items()} | {"umdh.path": str(umdh)} + + def action(name: str, arguments: tuple[str, ...], dependencies: tuple[Node, ...], + *, identity: Mapping[str, str] | None = None, + config: Mapping[str, str] | None = None, + results: tuple[Result, ...] = ()) -> Node: + return python_action( + factory, name, "core.leak", arguments, dependencies, + pool="slot", identity=identity, config=config, results=results, + ) + + ordered = tuple(selected[name] for name in _BINARIES) + setup_dependencies = (selected["leak-probe"][0],) + tuple( + dependency for name, (producer, _path) in zip(_BINARIES, ordered, strict=True) + if name != "leak-probe" + for dependency in (producer, *required_audit_gates(upstream, producer, "x64", name)) + ) + setup = action( + "leak-setup-x64-release", ("setup", *(str(path) for _producer, path in ordered)), setup_dependencies, + config={"architecture": "x64", "configuration": "Release"}, + ) + setup_output = paths.cas(setup.uid).output + nodes: list[Node] = [setup] + targets: list[str] = [] + + for mode in LEAK_MODES: + for scenario in LEAK_SCENARIOS: + stem = f"{mode}-{scenario}" + result_root = f"reports/leak/x64/{mode}/{scenario}" + preflight = action(f"leak-preflight-{stem}", + ("preflight", str(setup_output), mode, scenario), (setup,), + results=(Result( + f"{result_root}/preflight.json", "leak", + "application/json", "preflight.json", + ),)) + capture = action(f"leak-capture-{stem}", + ("capture", str(setup_output), str(umdh), mode, scenario, + str(warmup), str(iterations), str(windows)), + (preflight,), identity=umdh_signature, config={"run_nonce": run_nonce}, + results=( + Result(f"{result_root}/capture.json", "leak", "application/json", "capture.json"), + Result(f"{result_root}/snapshots", "leak-snapshots", "application/octet-stream", "snapshots"), + Result(f"{result_root}/probe.stderr.log", "log", "text/plain", "probe.stderr.log"), + )) + capture_output = paths.cas(capture.uid).output + snapshots = capture_output / "snapshots" + specs = [(f"window-{index}", snapshots / f"window-{index}.txt", snapshots / f"window-{index + 1}.txt") + for index in range(1, windows)] + specs.append(("overall", snapshots / "window-1.txt", snapshots / f"window-{windows}.txt")) + diffs = tuple( + action(f"leak-diff-{stem}-{label}", + ("diff", str(umdh), str(setup_output), label, str(before), str(after)), + (capture,), identity=umdh_signature, + results=( + Result(f"{result_root}/diffs/{label}.json", "leak-diff", "application/json", "diff.json"), + Result(f"{result_root}/diffs/{label}.txt", "leak-diff", "text/plain", "report.txt"), + )) + for label, before, after in specs + ) + summary = action(f"leak-summary-{stem}", + ( + "summarize", mode, scenario, str(warmup), str(iterations), str(windows), + str(tolerance_bytes), + *(value for (label, _before, _after), item in zip(specs, diffs, strict=True) + for value in (label, str(paths.cas(item.uid).output / "diff.json"))), + ), diffs, results=(Result( + f"{result_root}/summary.json", "leak-summary", + "application/json", "summary.json", + ),)) + gate = action( + f"leak-gate-{stem}", + ("gate", str(paths.cas(summary.uid).output / "summary.json")), + (summary,), + ) + nodes.extend((preflight, capture, *diffs, summary, gate)) + targets.append(gate.name) + + pools = extend_pools(upstream, {"slot": jobs}) + return Graph(upstream.nodes + tuple(nodes), tuple(targets), pools) diff --git a/build/graphs/native.py b/build/graphs/native.py new file mode 100644 index 0000000..7424c94 --- /dev/null +++ b/build/graphs/native.py @@ -0,0 +1,223 @@ +"""Fine-grained native project build and Catch2 execution graph.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from core.graph import Graph, Node, Result, merge_graphs +from core.node import NodeFactory +from core.paths import BuildPaths +from core.toolchain import MsvcToolchain +from graphs.analysis import ( + dependency_discovery_slice, + project_build, + project_inventory, +) +from graphs.common import ( + BINARIES, + PLATFORMS, + PROJECTS, + recipe_factory, + require_positive_integers, + tool_environment, +) + + +_CONFIGURATIONS = ("Debug", "Release") +def _validate_matrix( + jobs: int, architectures: tuple[str, ...], configurations: tuple[str, ...] +) -> None: + require_positive_integers((jobs,), "jobs must be a positive integer") + unsupported = set(architectures) - PLATFORMS.keys() + if unsupported: + raise ValueError(f"unsupported architecture: {sorted(unsupported)[0]}") + invalid = set(configurations) - set(_CONFIGURATIONS) + if invalid: + raise ValueError(f"unsupported configuration: {sorted(invalid)[0]}") + + +def native_dependency_discovery_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), + configurations: tuple[str, ...] = ("Debug",), + include_leak_probe: bool = False, +) -> Graph: + """Discover exact native build dependencies per configuration and TU.""" + + _validate_matrix(jobs, architectures, configurations) + graphs = tuple( + dependency_discovery_slice( + repository, + toolchain, + project_names=PROJECTS + ( + ("leak-probe",) if include_leak_probe and config == "Release" else () + ), + configuration=config, + name_qualifier=config.lower(), + jobs=jobs, + architectures=architectures, + ) + for config in configurations + ) + return merge_graphs(*graphs) + + +def _test_shard( + repository: Path, + toolchain: MsvcToolchain, + factory: NodeFactory, + paths: BuildPaths, + builds: tuple[Node, ...], + architecture: str, + configuration: str, + shard_count: int, + shard_index: int, + corpus: Path | None, + run_nonce: str, +) -> Node: + artifacts = [ + { + "name": BINARIES[project], + "source": str(paths.cas(node.uid).output / BINARIES[project]), + } + for project, node in zip(PROJECTS, builds, strict=True) + ] + prefix = "corpus" if corpus is not None else "test" + result_scope = "corpus" if corpus is not None else "unit" + name = f"{prefix}-shard-{architecture}-{configuration.lower()}-{shard_index}" + config = { + "action": "test", + "architecture": architecture, + "configuration": configuration, + "shard_count": str(shard_count), + "shard_index": str(shard_index), + } + options = {} + if corpus is not None: + config |= {"corpus": str(corpus), "run_nonce": run_nonce} + options["environment"] = tool_environment( + toolchain, extra=(("OBSERVER_TEST_CORPUS", str(corpus)),) + ) + return factory.make( + "native-corpus-test.ps1" if corpus is not None else "native-test.ps1", + name, + "slot", + { + "pwsh": str(toolchain.pwsh), + "artifacts": artifacts, + "shard_count": shard_count, + "shard_index": shard_index, + }, + files={}, + dependencies=builds, + results=(Result( + f"reports/tests/{architecture}/{configuration.lower()}/{result_scope}/" + f"shard-{shard_index}.xml", + "test", + "application/xml", + "tests.xml", + ),), + config=config, + **options, + ) + + +def native_graph( + repository: Path, + toolchain: MsvcToolchain, + *, + discovery: Graph | None = None, + manifests: Mapping[str, bytes] | None = None, + jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), + configurations: tuple[str, ...] = ("Debug",), + runnable_architectures: tuple[str, ...], + test_shards: int = 4, + include_leak_probe: bool = False, + corpus: Path | None = None, + run_nonce: str = "", +) -> Graph: + """Return independently cacheable project builds and safe Catch2 shards.""" + + _validate_matrix(jobs, architectures, configurations) + require_positive_integers((test_shards,), "test_shards must be a positive integer") + unsupported = set(runnable_architectures) - PLATFORMS.keys() + if unsupported: + raise ValueError(f"unsupported architecture: {sorted(unsupported)[0]}") + corpus_path = None + if corpus is not None: + if not isinstance(run_nonce, str) or not run_nonce or "\0" in run_nonce: + raise ValueError("corpus run nonce must be a non-empty string without NUL") + corpus_path = Path(corpus).resolve(strict=True) + if not corpus_path.is_dir(): + raise NotADirectoryError(corpus_path) + if discovery is None or manifests is None: + raise ValueError("native dependency discovery is required") + + root = repository.resolve(strict=True) + factory = recipe_factory(root, dict(toolchain.identity), tool_environment(toolchain)) + paths = BuildPaths(root) + nodes: list[Node] = list(discovery.nodes) + targets: list[str] = [] + runnable = set(runnable_architectures) + projects = project_inventory(root, PROJECTS) + + for architecture in architectures: + restore = discovery.node(f"restore-vcpkg-{architecture}") + restore_output = paths.cas(restore.uid).output + for configuration in configurations: + def build(project): + return project_build( + root, factory, discovery, manifests, restore_output, project, + architecture, configuration.lower(), "native-build.ps1", { + "pwsh": str(toolchain.pwsh), "msbuild": str(toolchain.msbuild), + "project": str(project.path), "target": "Build", + "configuration": configuration, "platform": PLATFORMS[architecture], + "vcpkg_root": str(toolchain.vcpkg_root), + "vcpkg_installed": str(restore_output), + }, config={ + "action": "build", "architecture": architecture, + "configuration": configuration, "project": project.name, + }, + ) + + builds = tuple(build(project) for project in projects) + nodes.extend(builds) + leak_probe = None + if include_leak_probe and architecture == "x64" and configuration == "Release": + leak_probe = build(project_inventory(root, ("leak-probe",))[0]) + nodes.append(leak_probe) + + if architecture in runnable: + runs = [(None, "")] + if corpus_path is not None: + runs.append((corpus_path, run_nonce)) + for shard_corpus, nonce in runs: + shards = tuple( + _test_shard( + root, + toolchain, + factory, + paths, + builds, + architecture, + configuration, + test_shards, + index, + shard_corpus, + nonce, + ) + for index in range(test_shards) + ) + nodes.extend(shards) + targets.extend(node.name for node in shards) + else: + targets.extend(node.name for node in builds) + if leak_probe is not None: + targets.append(leak_probe.name) + + return Graph(tuple(nodes), tuple(targets), {"restore": 1, "slot": jobs}) diff --git a/build/graphs/package.py b/build/graphs/package.py new file mode 100644 index 0000000..59f4c8c --- /dev/null +++ b/build/graphs/package.py @@ -0,0 +1,184 @@ +"""Fine-grained deterministic packaging over explicit release build artifacts.""" + +from __future__ import annotations + +from pathlib import Path + +from core.graph import Graph, Node, Result +from core.package import ARCHITECTURES, LICENSES, MODULES +from core.paths import BuildPaths +from graphs.common import ( + BUILD_ROOT, canonical_artifact, extend_pools, python_action, recipe_factory, required_audit_gates, require_positive_integers, +) + + +def package_outputs(repository: Path, graph: Graph) -> tuple[Path, ...]: + """Return gated ZIP paths from the final package-manifest producer.""" + + paths = BuildPaths(repository.resolve(strict=True)) + try: + aggregate = graph.node("package-manifest") + except ValueError as error: + raise ValueError("graph has no package outputs") from error + output = paths.cas(aggregate.uid).output + result = [output / item.relative_path for item in aggregate.results if item.kind == "package"] + if not result: + raise ValueError("graph has no package outputs") + return tuple(result) + + +def package_graph( + repository: Path, + upstream: Graph, + *, + architectures: tuple[str, ...], + smoke_architectures: tuple[str, ...] = (), + jobs: int = 4, +) -> Graph: + """Compose module and symbol packages without artificial cross-architecture edges.""" + + require_positive_integers((jobs,), "package jobs must be a positive integer") + root = repository.resolve(strict=True) + paths = BuildPaths(root) + factory = recipe_factory(BUILD_ROOT, {}) + + def action(name: str, arguments: tuple[str, ...], dependencies: tuple[Node, ...], + files: dict[str, bytes] | None = None, + results: tuple[Result, ...] = ()) -> Node: + return python_action(factory, name, "core.package", arguments, dependencies, + pool="package", files=files, results=results) + + if ( + not architectures + or len(architectures) != len(set(architectures)) + or any(item not in ARCHITECTURES for item in architectures) + ): + raise ValueError("package architectures must be a unique non-empty supported set") + if ( + len(smoke_architectures) != len(set(smoke_architectures)) + or any(item not in architectures for item in smoke_architectures) + ): + raise ValueError("package smoke architectures must be a unique package subset") + selected_architectures = tuple(sorted(architectures)) + + nodes: list[Node] = [] + archives: dict[str, Node] = {} + validations: dict[str, Node] = {} + symbol_stages = {architecture: [] for architecture in selected_architectures} + + def output(node: Node, name: str = "") -> Path: + return paths.cas(node.uid).output / name + + for architecture in selected_architectures: + for module in MODULES: + producer, binary = canonical_artifact( + paths, upstream, f"build-{module}-{architecture}-release", f"{module}.so" + ) + suffix = f"{architecture}-{module}" + symbols = paths.cas(producer.uid).output / f"{module}.pdb" + gates = required_audit_gates(upstream, producer, architecture, module) + dependencies = (producer, *gates) + + repository_inputs = ( + f"src/modules/{module}/observer_user.ini", + "LICENSE.txt", + *(f"licenses/{name}" for name in LICENSES[module]), + ) + stage = action( + f"package-stage-{suffix}", + ("stage-module", architecture, module, str(binary), str(root)), + dependencies, + {name: (root / name).read_bytes() for name in repository_inputs}, + ) + symbol_stage = action( + f"package-symbol-stage-{suffix}", + ("stage-symbol", architecture, module, str(symbols)), + dependencies, + ) + archive = action( + f"package-archive-{suffix}", + ("archive-module", architecture, module, str(output(stage))), + (stage, *gates), + ) + archive_name = f"{module}-{architecture}-dll.zip" + validation = action( + f"package-validate-{suffix}", + ("validate-module", architecture, module, str(output(archive, archive_name)), str(output(stage))), + (archive, stage), + results=(Result( + f"reports/package/{architecture}/{module}/validation.json", + "package-validation", "application/json", "validation.json", + ),), + ) + nodes.extend((stage, symbol_stage, archive, validation)) + archives[archive_name] = archive + validations[archive_name] = validation + symbol_stages[architecture].append(symbol_stage) + + for architecture, stages in sorted(symbol_stages.items()): + current = action( + f"package-symbols-{architecture}", + ( + "archive-symbols", architecture, + *(str(output(stage)) for stage in stages), + ), + tuple(stages), + ) + nodes.append(current) + archive_name = f"observer-modules-{architecture}-pdb.zip" + validation = action( + f"package-symbols-validate-{architecture}", + ("validate-symbols", architecture, str(output(current, archive_name)), + *(str(output(stage)) for stage in stages)), + (current, *stages), + results=(Result( + f"reports/package/{architecture}/symbols/validation.json", + "package-validation", "application/json", "validation.json", + ),), + ) + nodes.append(validation) + archives[archive_name] = current + validations[archive_name] = validation + + smoke_nodes = [] + for architecture in sorted(smoke_architectures): + test_producer, test_executable = canonical_artifact( + paths, upstream, f"build-tests-{architecture}-release", "tests.exe" + ) + for module in MODULES: + archive_name = f"{module}-{architecture}-dll.zip" + archive = archives[archive_name] + validation = validations[archive_name] + smoke_node = action( + f"package-smoke-{architecture}-{module}", + ( + "smoke", architecture, module, + str(output(archive, archive_name)), + str(test_executable), + ), + (validation, test_producer), + ) + nodes.append(smoke_node) + smoke_nodes.append(smoke_node) + + package_results = tuple( + Result(f"packages/{architecture}/{name}", "package", "application/zip", name) + for architecture in sorted(symbol_stages) + for name in ( + *(f"{module}-{architecture}-dll.zip" for module in MODULES), + f"observer-modules-{architecture}-pdb.zip", + ) + ) + aggregate = action( + "package-manifest", + ( + "aggregate", + *(str(output(node, name)) for name, node in archives.items()), + ), + tuple(validations.values()) + tuple(smoke_nodes), + results=package_results + (Result( + "packages/packages.json", "package-manifest", "application/json", "packages.json", + ),), + ) + nodes.append(aggregate) + return Graph(upstream.nodes + tuple(nodes), (aggregate.name,), extend_pools(upstream, {"package": jobs})) diff --git a/build/graphs/python_coverage.py b/build/graphs/python_coverage.py new file mode 100644 index 0000000..0051c07 --- /dev/null +++ b/build/graphs/python_coverage.py @@ -0,0 +1,60 @@ +"""Content-addressed Python line-and-branch coverage gate.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from core.graph import Graph, Result +from graphs.common import BUILD_ROOT, python_action, recipe_factory + + +def _inputs(repository: Path, build_root: Path) -> dict[str, bytes]: + paths = [ + build_root / name + for name in ("driver.py", "main.py", "pyproject.toml", "uv.lock") + ] + for directory in ("core", "graphs", "tests"): + paths.extend(sorted((build_root / directory).rglob("*.py"))) + return {path.relative_to(repository).as_posix(): path.read_bytes() for path in paths} + + +def _tool_digest(build_root: Path, executable: Path) -> str: + package = (build_root / ".venv/Lib/site-packages/coverage").resolve(strict=True) + if not package.is_dir(): + raise FileNotFoundError(f"project coverage package is not a directory: {package}") + paths = [executable] + [path for path in sorted(package.rglob("*")) + if path.is_file() and "__pycache__" not in path.parts] + digest = hashlib.sha256() + for path in paths: + digest.update(path.relative_to(build_root).as_posix().encode() + b"\0") + with path.open("rb") as stream: + digest.update(hashlib.file_digest(stream, "sha256").digest()) + return digest.hexdigest() + + +def python_coverage_graph(repository: Path) -> Graph: + """Build one demand gate using only the repository-local pinned coverage executable.""" + + root = repository.resolve(strict=True) + build_root = root / "build" + coverage = (build_root / ".venv/Scripts/coverage.exe").resolve(strict=True) + if not coverage.is_file(): + raise FileNotFoundError(f"project coverage executable is not a file: {coverage}") + digest = _tool_digest(build_root, coverage) + factory = recipe_factory(BUILD_ROOT, {}) + gate = python_action( + factory, "python-coverage", "core.python_coverage", (str(coverage), str(build_root)), (), + pool="python-coverage", files=_inputs(root, build_root), + identity={"coverage.path": str(coverage), "coverage.sha256": digest}, + config={"action": "python-coverage", "coverage": "100-percent-line-and-branch"}, + environment=(("PYTHONDONTWRITEBYTECODE", "1"),), + results=( + Result("reports/coverage/python/coverage.json", "coverage", "application/json", "coverage.json"), + Result("reports/coverage/python/coverage.xml", "coverage", "application/xml", "coverage.xml"), + Result("reports/coverage/python/coverage.txt", "coverage", "text/plain", "coverage.txt"), + Result("reports/coverage/python/coverage.toml", "coverage-config", "application/toml", "coverage.toml"), + Result("reports/coverage/python/coverage.data", "coverage-data", "application/octet-stream", ".coverage"), + ), + ) + return Graph((gate,), (gate.name,), {"python-coverage": 1}) diff --git a/build/graphs/sanitizer.py b/build/graphs/sanitizer.py new file mode 100644 index 0000000..c3758a6 --- /dev/null +++ b/build/graphs/sanitizer.py @@ -0,0 +1,262 @@ +"""Fine-grained ASan/UBSan tests over explicit sanitizer build artifacts.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path + +from core.graph import Graph, Node, Result +from core.paths import BuildPaths +from core.toolchain import MsvcToolchain +from graphs.common import ( + BINARIES, + BUILD_ROOT, + canonical_artifact, + extend_pools, + prefixed_identity, + python_action, + recipe_factory, + require_ancestor, + require_positive_integers, + require_tool, + tool_environment, +) +from graphs.instrumented import InstrumentedVariant, instrumented_build_slice, instrumented_dependency_discovery_slice + + +_SUPPORTED = {("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")} +_RUNTIME_NAMES = { + "x86": "clang_rt.asan_dynamic-i386.dll", + "x64": "clang_rt.asan_dynamic-x86_64.dll", +} +_OPTIONS = { + "asan": ("ASAN_OPTIONS", "halt_on_error=1:alloc_dealloc_mismatch=1"), + "ubsan": ("UBSAN_OPTIONS", "halt_on_error=1:print_stacktrace=1"), +} + + +@dataclass(frozen=True, slots=True) +class AsanRuntime: + architecture: str + path: Path + identity: Mapping[str, str] + + +def _builds( + paths: BuildPaths, + upstream: Graph, + selections: tuple[tuple[str, str], ...], +) -> dict[tuple[str, str], dict[str, tuple[Node, Path]]]: + if not selections or len(selections) != len(set(selections)) or any( + item not in _SUPPORTED for item in selections + ): + raise ValueError("sanitizer selections must be a unique non-empty supported set") + selected = {} + for sanitizer, architecture in selections: + group = {} + for name, filename in BINARIES.items(): + producer, path = canonical_artifact( + paths, upstream, f"build-{name}-{architecture}-{sanitizer}", filename + ) + group[name] = (producer, path) + restore = ( + f"restore-vcpkg-asan-{architecture}" + if sanitizer == "asan" + else f"restore-vcpkg-{architecture}" + ) + for producer, _path in group.values(): + require_ancestor( + upstream, producer, restore, + f"sanitizer build requires {restore} as a restore ancestor", + ) + selected[(sanitizer, architecture)] = group + return selected + + +def _runtimes( + selected: Mapping[tuple[str, str], object], runtimes: Iterable[AsanRuntime] +) -> dict[str, AsanRuntime]: + result = {} + for runtime in runtimes: + path = require_tool(runtime.path, "ASan runtime") + if ( + runtime.architecture not in _RUNTIME_NAMES + or path.name != _RUNTIME_NAMES[runtime.architecture] + or not runtime.identity + ): + raise ValueError(f"invalid ASan runtime identity: {runtime.architecture}") + if runtime.architecture in result: + raise ValueError(f"duplicate ASan runtime: {runtime.architecture}") + result[runtime.architecture] = AsanRuntime( + runtime.architecture, path, runtime.identity + ) + required = {architecture for sanitizer, architecture in selected if sanitizer == "asan"} + if result.keys() != required: + raise ValueError("an exact ASan runtime is required for each ASan artifact set") + return result + + +def sanitizer_artifact_graph( + repository: Path, + upstream: Graph, + *, + selections: tuple[tuple[str, str], ...], + pwsh: Path, + pwsh_identity: Mapping[str, str], + asan_runtimes: Iterable[AsanRuntime] = (), + environment: tuple[tuple[str, str], ...] = (), + test_shards: int = 4, + jobs: int = 4, +) -> Graph: + """Compose test-only sanitizer shards and fail-closed log gates.""" + + require_positive_integers( + (test_shards, jobs), + "sanitizer counts and pool capacities must be positive integers", + ) + root = repository.resolve(strict=True) + paths = BuildPaths(root) + pwsh = require_tool(pwsh, "PowerShell") + selected = _builds(paths, upstream, selections) + runtimes = _runtimes(selected, asan_runtimes) + pwsh_id = prefixed_identity("pwsh", pwsh, pwsh_identity) + gate_factory = recipe_factory(BUILD_ROOT, {}) + nodes: list[Node] = [] + targets: list[str] = [] + + for (sanitizer, architecture), group in sorted(selected.items()): + builds = tuple(group[name][0] for name in BINARIES) + runtime = runtimes.get(architecture) if sanitizer == "asan" else None + identity = pwsh_id + runtime_copy = None + if runtime is not None: + identity = identity | prefixed_identity( + f"asan_runtime.{architecture}", runtime.path, runtime.identity + ) + runtime_copy = {"name": runtime.path.name, "source": str(runtime.path)} + factory = recipe_factory(root, identity, environment) + copies = tuple( + {"name": BINARIES[name], "source": str(group[name][1])} + for name in BINARIES + ) + options_name, options_value = _OPTIONS[sanitizer] + for index in range(test_shards): + shard = factory.make( + "sanitizer-test.ps1", + f"{sanitizer}-test-{architecture}-{index}", + "sanitizer-shard", + { + "pwsh": str(pwsh), "artifacts": copies, "runtime": runtime_copy, + "options_name": options_name, "options_value": options_value, + "shard_count": test_shards, "shard_index": index, + }, + files={}, dependencies=builds, + results=(Result( + f"reports/sanitizers/{sanitizer}/{architecture}/shard-{index}.xml", + "sanitizer", + "application/xml", + "tests.xml", + ),), + config={ + "action": "sanitizer-test", "sanitizer": sanitizer, + "architecture": architecture, "shard_count": str(test_shards), + "shard_index": str(index), + }, + ) + log = paths.cas(shard.uid).log + gate = python_action( + gate_factory, + f"{sanitizer}-gate-{architecture}-{index}", + "core.sanitizer", + ("gate", sanitizer, str(log)), + (shard,), + pool="sanitizer-gate", + ) + nodes.extend((shard, gate)) + targets.append(gate.name) + + return Graph( + upstream.nodes + tuple(nodes), tuple(targets), + extend_pools(upstream, {"sanitizer-shard": jobs, "sanitizer-gate": jobs}), + ) + + +def _instrumented_variants( + selections: tuple[tuple[str, str], ...], + llvm_runtime: Path | None, + llvm_runtime_identity: Mapping[str, str], +) -> tuple[InstrumentedVariant, ...]: + return tuple( + InstrumentedVariant( + sanitizer, + architecture, + llvm_runtime if sanitizer == "ubsan" else None, + llvm_runtime_identity if sanitizer == "ubsan" else {}, + ) + for sanitizer, architecture in selections + ) + + +def sanitizer_dependency_discovery_slice( + repository: Path, + toolchain: MsvcToolchain, + *, + llvm_runtime: Path | None, + llvm_runtime_identity: Mapping[str, str], + selections: tuple[tuple[str, str], ...] = ( + ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64") + ), + jobs: int = 2, +) -> Graph: + """Discover exact TU inputs for each requested sanitizer build.""" + + return instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=_instrumented_variants( + selections, llvm_runtime, llvm_runtime_identity + ), + jobs=jobs, + ) + + +def sanitizer_graph( + repository: Path, + toolchain: MsvcToolchain, + *, + discovery: Graph | None, + manifests: Mapping[str, bytes], + llvm_runtime: Path | None, + llvm_runtime_identity: Mapping[str, str], + asan_runtimes: Iterable[AsanRuntime] = (), + selections: tuple[tuple[str, str], ...] = ( + ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64") + ), + jobs: int = 4, + test_shards: int = 4, +) -> Graph: + """Build sanitizer artifacts and compose independent shard gates.""" + + variants = _instrumented_variants( + selections, llvm_runtime, llvm_runtime_identity + ) + upstream = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=variants, + jobs=jobs, + ) + return sanitizer_artifact_graph( + repository, + upstream, + selections=selections, + pwsh=toolchain.pwsh, + pwsh_identity=dict(toolchain.identity), + asan_runtimes=asan_runtimes, + environment=tool_environment(toolchain), + test_shards=test_shards, + jobs=jobs, + ) diff --git a/build/graphs/source.py b/build/graphs/source.py new file mode 100644 index 0000000..4ceede9 --- /dev/null +++ b/build/graphs/source.py @@ -0,0 +1,234 @@ +"""Fine-grained repository source checks.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path + +from core.graph import Graph, Node, Result +from core.paths import BuildPaths +from core.source_tools import SourceTools +from graphs.common import python_action, recipe_factory, restore_node, tool_environment + + +_BUILD_ROOT = Path(__file__).resolve().parents[1] +_CPP_SUFFIXES = {".cpp", ".h", ".hpp"} +_POWERSHELL_SUFFIXES = {".ps1", ".psm1"} +_CPPCHECK_ARCHITECTURES = { + "x86": ("win32W", "_M_IX86=600"), + "x64": ("win64", "_M_X64=100"), + "arm64": ("win64", "_M_ARM64=1"), +} +_IGNORED_DIRECTORIES = {".git", ".venv", "__pycache__", "out"} + + +def _relative(repository: Path, path: Path) -> str: + return path.resolve(strict=True).relative_to(repository).as_posix() + + +def _slug(repository: Path, path: Path) -> str: + return _relative(repository, path).lower().replace("/", ".") + + +def _repository_files(repository: Path) -> tuple[Path, ...]: + files = [] + for directory, directories, names in repository.walk(): + directories[:] = sorted(set(directories) - _IGNORED_DIRECTORIES) + files.extend(directory / name for name in names if not name.startswith(".coverage")) + return tuple(sorted(files)) + + +def source_checks(repository: Path, tools: SourceTools, jobs: int = 4, + architectures: Iterable[str] = _CPPCHECK_ARCHITECTURES) -> Graph: + """Return independently cacheable format, analyzer, and contract checks.""" + + selected_architectures = tuple(architectures) + if not selected_architectures: + raise ValueError("source architectures must be unique supported architecture names") + return _source_checks(repository, tools, jobs, selected_architectures, include_common=True) + + +def common_source_checks(repository: Path, tools: SourceTools, jobs: int = 4) -> Graph: + """Return only architecture-independent repository checks.""" + + return _source_checks(repository, tools, jobs, (), include_common=True) + + +def architecture_source_checks(repository: Path, tools: SourceTools, + architectures: Iterable[str], jobs: int = 4) -> Graph: + """Return only architecture-parameterized Cppcheck gates.""" + + return _source_checks( + repository, tools, jobs, tuple(architectures), include_common=False + ) + + +def _source_checks(repository: Path, tools: SourceTools, jobs: int, + selected_architectures: tuple[str, ...], *, include_common: bool) -> Graph: + if ((not selected_architectures and not include_common) or + len(set(selected_architectures)) != len(selected_architectures) or + any(item not in _CPPCHECK_ARCHITECTURES for item in selected_architectures)): + raise ValueError("source architectures must be unique supported architecture names") + root = repository.resolve(strict=True) + identities = dict(tools.identity) + restore_identity = {key: identities[key] for key in ("pwsh", "pwsh_version", "vcpkg_root")} + factory = recipe_factory(root, {}, tools.environment) + restore_factory = recipe_factory(root, restore_identity, tool_environment(tools)) + paths = BuildPaths(root) + + def tool_identity(*names: str) -> dict[str, str]: + return {key: identities[key] for name in names for key in (name, f"{name}_version")} + + cpp_sources = tuple(sorted( + path for path in (root / "src").rglob("*") + if path.is_file() and path.suffix in _CPP_SUFFIXES + )) + template_root = root / "build/templates" + powershell_sources = (root / "build.ps1",) + tuple(sorted( + path for path in (root / "build").rglob("*") + if ( + path.is_file() + and path.suffix in _POWERSHELL_SUFFIXES + # Raw Jinja is not PowerShell; rendered recipes are parser-tested below this graph. + and not path.is_relative_to(template_root) + ) + )) + contracts = tuple(sorted((root / "build/tests").glob("*.Tests.ps1"))) + + def leaf( + template: str, + name: str, + variables: dict[str, object], + inputs: tuple[Path, ...], + *, + dependencies: tuple[Node, ...] = (), + identity: dict[str, str], + config: dict[str, str], + ) -> Node: + return factory.make(template, name, "slot", variables, + files={_relative(root, path): path.read_bytes() for path in inputs}, + dependencies=dependencies, identity=identity, config=config, + ) + + format_nodes = tuple( + leaf( + "clang-format.ps1", + f"format-{_slug(root, source)}", + { + "pwsh": str(tools.pwsh), + "clang_format": str(tools.clang_format), + "source": str(source), + }, + (root / ".clang-format", source), + identity=tool_identity("pwsh", "clang_format"), + config={"action": "format", "source": _relative(root, source)}, + ) + for source in cpp_sources + ) if include_common else () + + restore_nodes = [] + cppcheck_nodes = [] + for architecture in selected_architectures: + platform, architecture_define = _CPPCHECK_ARCHITECTURES[architecture] + triplet = f"observer-{architecture}-windows-static" + restore = restore_node(root, tools, restore_factory, architecture) + restore_nodes.append(restore) + include_dir = paths.cas(restore.uid).output / triplet / "include" + cppcheck_nodes.append( + leaf( + "cppcheck.ps1", + f"cppcheck-{architecture}", + { + "pwsh": str(tools.pwsh), + "cppcheck": str(tools.cppcheck), + "repository": str(root), + "source_dir": str(root / "src"), + "include_dir": str(include_dir), + "triplet": triplet, + "platform": platform, + "architecture_define": architecture_define, + "automation_id": f"cppcheck/{architecture}/", + }, + cpp_sources, + dependencies=(restore,), + identity=tool_identity("pwsh", "cppcheck"), + config={"action": "cppcheck", "architecture": architecture}, + ) + ) + cppcheck_nodes = tuple(cppcheck_nodes) + restore_nodes = tuple(restore_nodes) + + settings = root / "build/PSScriptAnalyzerSettings.psd1" + pssa_nodes = tuple( + leaf( + "psscriptanalyzer.ps1", + f"pssa-{_slug(root, source)}", + { + "pwsh": str(tools.pwsh), + "psscriptanalyzer": str(tools.psscriptanalyzer), + "repository": str(root), + "source": str(source), + "settings": str(settings), + "automation_id": f"psscriptanalyzer/{_relative(root, source)}/", + }, + (settings, source), + identity=tool_identity("pwsh", "psscriptanalyzer"), + config={"action": "psscriptanalyzer", "source": _relative(root, source)}, + ) + for source in powershell_sources + ) if include_common else () + + contract_inputs = _repository_files(root) + contract_nodes = tuple( + leaf( + "contract.ps1", + f"contract-{_slug(root, test)}", + { + "pwsh": str(tools.pwsh), + "test": str(test), + }, + contract_inputs, + identity=tool_identity("pwsh"), + config={"action": "contract", "test": _relative(root, test)}, + ) + for test in contracts + ) if include_common else () + + def output(current: Node) -> Path: + return paths.cas(current.uid).output + + def report(current: Node) -> Path: + name = "cppcheck.sarif" if current.name.startswith("cppcheck-") else "psscriptanalyzer.sarif" + return output(current) / name + + finding_nodes = cppcheck_nodes + pssa_nodes + qualifier = "-".join(selected_architectures) + merge_name = "merge-source-findings" if include_common else f"merge-cppcheck-findings-{qualifier}" + gate_name = "source-checks" if include_common else f"cppcheck-checks-{qualifier}" + result_id = ( + "reports/sarif/source/analysis.sarif" + if include_common else f"reports/sarif/{qualifier}/cppcheck.sarif" + ) + merged = python_action( + factory, + merge_name, + "core.sarif", + ("merge", *(str(report(current)) for current in finding_nodes)), + finding_nodes, + pool="slot", + results=(Result( + result_id, "report", "application/sarif+json", "analysis.sarif" + ),), + ) + direct = format_nodes + contract_nodes + gate = python_action( + factory, + gate_name, + "core.sarif", + ("gate", str(output(merged) / "analysis.sarif")), + (merged,) + direct, + pool="slot", + ) + nodes = restore_nodes + format_nodes + cppcheck_nodes + pssa_nodes + contract_nodes + (merged, gate) + pools = {"slot": jobs} | ({"restore": 1} if restore_nodes else {}) + return Graph(nodes, (gate.name,), pools) diff --git a/build/main.py b/build/main.py new file mode 100644 index 0000000..4b360ae --- /dev/null +++ b/build/main.py @@ -0,0 +1,202 @@ +"""PowerShell-compatible table-driven CLI for the local build DAG.""" + +from __future__ import annotations + +import argparse +import asyncio +from collections.abc import Awaitable, Callable, Sequence +from datetime import UTC, datetime +import os +from pathlib import Path +import sys + +from core.doctor import main as doctor_main +from core.host import verify_route +from core.quality_tools import resolve_binskim, resolve_dumpbin, resolve_umdh +from core.source_tools import discover_source_tools +from core.toolchain import discover_msvc_toolchain +from driver import Driver as BuildDriver + + +_ARCHITECTURES = ("x86", "x64", "arm64") +_CONFIGURATIONS = ("Debug", "Release") +_FUZZ_TARGETS = ("pickle", "renpy", "rpgmaker", "zanzarah") + + +def _selection(choices: tuple[str, ...]) -> Callable[[str], tuple[str, ...]]: + def parse(value: str) -> tuple[str, ...]: + parts = tuple(item.strip() for item in value.split(",")) + if any(item.casefold() == "all" for item in parts): + return choices + lookup = {item.casefold(): item for item in choices} + try: + selected = tuple(lookup[item.casefold()] for item in parts if item) + except KeyError as error: + raise argparse.ArgumentTypeError(f"expected all or comma-separated: {','.join(choices)}") + if len(selected) != len(parts): + raise argparse.ArgumentTypeError(f"expected all or comma-separated: {','.join(choices)}") + return tuple(dict.fromkeys(selected)) + return parse + + +def _integer(minimum: int, maximum: int | None = None) -> Callable[[str], int]: + def parse(value: str) -> int: + try: + number = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("expected an integer") from error + if number < minimum or maximum is not None and number > maximum: + limit = f"{minimum}..{maximum}" if maximum is not None else f">= {minimum}" + raise argparse.ArgumentTypeError(f"expected {limit}") + return number + return parse + + +_OPTIONS: dict[str, tuple[tuple[str, ...], dict[str, object]]] = { + "arch": (("-Arch", "--arch"), {"type": _selection(_ARCHITECTURES), "default": ("x64",)}), + "config": (("-Config", "--config"), {"type": _selection(_CONFIGURATIONS), "default": ("Debug",)}), + "corpus": (("-Corpus", "--corpus"), {"type": Path}), + "restore": (("-RestoreFlavor", "--restore-flavor"), { + "type": _selection(("default", "asan")), "default": ("default",), + }), + "shards": (("-TestShards", "--test-shards"), {"type": _integer(1), "default": 4}), + "fuzz_seconds": (("-FuzzSeconds", "--fuzz-seconds"), {"type": _integer(1, 86400), "default": 60}), + "fuzz_target": (("-FuzzTarget", "--fuzz-target"), {"type": _selection(_FUZZ_TARGETS), "default": _FUZZ_TARGETS}), + "leak_warmup": (("-LeakWarmup", "--leak-warmup"), {"type": _integer(1, 1_000_000), "default": 8}), + "leak_iterations": (("-LeakIterations", "--leak-iterations"), {"type": _integer(1, 1_000_000), "default": 100}), + "leak_windows": (("-LeakWindows", "--leak-windows"), {"type": _integer(3, 10), "default": 3}), + "leak_tolerance": (("-LeakToleranceBytes", "--leak-tolerance-bytes"), { + "type": _integer(0, 1_073_741_824), "default": 0, + }), + "export_dir": (("-ExportDir", "--export-dir"), {"type": Path}), + "prune_cas": (("-PruneCas", "--prune-cas"), {"action": "store_true"}), + "clean_mode": (("-CleanMode", "--clean-mode"), {"choices": ("all", "stale-work"), "default": "all"}), +} + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="observer-build") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("doctor") + for name, (options, _invoke) in _COMMANDS.items(): + command = commands.add_parser(name) + command.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[1]) + command.add_argument("-Jobs", "--jobs", type=_integer(1)) + for option in options: + flags, settings = _OPTIONS[option] + command.add_argument(*flags, dest=option, **settings) + clean = commands.add_parser("clean") + clean.add_argument("-Repository", "--repository", type=Path, default=Path(__file__).parents[1]) + flags, settings = _OPTIONS["clean_mode"] + clean.add_argument(*flags, dest="clean_mode", **settings) + return parser + + +def _run_id() -> str: + return datetime.now(UTC).strftime("%Y%m%dT%H%M%S") + f"-{os.getpid()}" + + +def run_clean(repository: Path, mode: str) -> int: + from core.clean import main as clean_main + return clean_main((str(repository), "--mode", mode)) + + +Invoker = Callable[[object, argparse.Namespace, object], Awaitable[tuple[Path, ...]]] + + +async def _restore(driver: object, args: argparse.Namespace, _toolchain: object) -> tuple[Path, ...]: + outputs: tuple[Path, ...] = () + if "default" in args.restore: + outputs += await driver.restore(args.arch, flavors=("",)) + if "asan" in args.restore: + architectures = tuple(item for item in args.arch if item != "arm64") + if architectures: + outputs += await driver.restore(architectures, flavors=("asan",)) + return outputs + + +_COMMANDS: dict[str, tuple[tuple[str, ...], Invoker]] = { + "restore": (("arch", "restore"), _restore), + "build": (("arch", "config"), lambda driver, args, _toolchain: driver.build(args.arch, args.config)), + "test": (("arch", "config", "corpus", "shards"), lambda driver, args, _toolchain: driver.test( + args.arch, args.config, test_shards=args.shards, corpus=args.corpus, run_nonce=args.run_nonce + )), + "source-checks": (("arch",), lambda driver, args, toolchain: driver.source_checks( + args.arch, discover_source_tools(toolchain) + )), + "compiler-analysis": (("arch",), lambda driver, args, _toolchain: driver.compiler_analysis(args.arch)), + "test-coverage": (("arch", "corpus", "shards"), lambda driver, args, _toolchain: driver.test_coverage( + args.arch, test_shards=args.shards, corpus=args.corpus, run_nonce=args.run_nonce + )), + "test-asan": (("arch", "shards"), lambda driver, args, _toolchain: driver.test_asan(args.arch, test_shards=args.shards)), + "test-ubsan": (("arch", "shards"), lambda driver, args, _toolchain: driver.test_ubsan(args.arch, test_shards=args.shards)), + "test-leaks": (("arch", "leak_warmup", "leak_iterations", "leak_windows", "leak_tolerance"), lambda driver, args, toolchain: driver.test_leaks( + run_nonce=args.run_nonce, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim(), + umdh=resolve_umdh(), warmup=args.leak_warmup, iterations=args.leak_iterations, + windows=args.leak_windows, tolerance_bytes=args.leak_tolerance, + )), + "fuzz": (("arch", "fuzz_seconds", "fuzz_target"), lambda driver, args, _toolchain: driver.fuzz( + run_nonce=args.run_nonce, seconds=args.fuzz_seconds, targets=args.fuzz_target + )), + "audit-binaries": (("arch",), lambda driver, args, toolchain: driver.audit( + args.arch, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim() + )), + "package": (("arch", "export_dir"), lambda driver, args, toolchain: driver.package( + args.arch, dumpbin=resolve_dumpbin(toolchain), binskim=resolve_binskim(), + export_dir=args.export_dir, + )), + "verify-source": (("export_dir",), lambda driver, args, _toolchain: driver.verify_source( + export_dir=args.export_dir, + )), + "verify-arch": (("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup", + "leak_iterations", "leak_windows", "leak_tolerance", "export_dir", + "prune_cas"), + lambda driver, args, _toolchain: driver.verify_arch( + args.arch, corpus=args.corpus, run_nonce=args.run_nonce, fuzz_seconds=args.fuzz_seconds, + test_shards=args.shards, warmup=args.leak_warmup, iterations=args.leak_iterations, + windows=args.leak_windows, tolerance_bytes=args.leak_tolerance, + export_dir=args.export_dir, + )), + "verify": (("arch", "corpus", "shards", "fuzz_seconds", "leak_warmup", + "leak_iterations", "leak_windows", "leak_tolerance", "export_dir", + "prune_cas"), + lambda driver, args, _toolchain: driver.verify( + args.arch, corpus=args.corpus, run_nonce=args.run_nonce, fuzz_seconds=args.fuzz_seconds, + test_shards=args.shards, warmup=args.leak_warmup, iterations=args.leak_iterations, + windows=args.leak_windows, tolerance_bytes=args.leak_tolerance, + export_dir=args.export_dir, + )), +} + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _parser() + arguments = list(sys.argv[1:] if argv is None else argv) + if not arguments or len(arguments) == 1 and arguments[0].casefold() == "help": + parser.print_help() + return 0 + args = parser.parse_args(arguments) + if args.command == "doctor": + return doctor_main(()) + if args.command == "clean": + return run_clean(args.repository, args.clean_mode) + if args.command in {"fuzz", "test-leaks"} and args.arch != ("x64",): + parser.error(f"{args.command} requires -Arch x64") + if args.command == "verify-arch" and len(args.arch) != 1: + parser.error("verify-arch requires exactly one architecture") + args.run_nonce = _run_id() + toolchain = discover_msvc_toolchain() + driver = BuildDriver( + args.repository, args.run_nonce, toolchain, + jobs=args.jobs, prune_cas=getattr(args, "prune_cas", False), + ) + outputs = asyncio.run(_COMMANDS[args.command][1](driver, args, toolchain)) + if args.command in {"verify", "verify-arch"}: + for item in verify_route(args.arch).deferred: + print(f"[DEFERRED] {item.gate} {item.architecture}: {item.reason}") + for output in outputs: + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/projects/fuzz-pickle.vcxproj b/build/projects/fuzz-pickle.vcxproj new file mode 100644 index 0000000..d7f0a77 --- /dev/null +++ b/build/projects/fuzz-pickle.vcxproj @@ -0,0 +1,36 @@ + + + + + 17.0 + {202A197D-CF6A-47D3-A379-64241A579795} + Win32Proj + fuzz_pickle + 10.0 + Application + + + + + + + + + + fuzz-pickle + + + + $(IntDir)pickle_fuzzer.obj + + + $(IntDir)pickle_parser.obj + + + + + + + + + diff --git a/build/projects/fuzz-renpy.vcxproj b/build/projects/fuzz-renpy.vcxproj new file mode 100644 index 0000000..70a9cd1 --- /dev/null +++ b/build/projects/fuzz-renpy.vcxproj @@ -0,0 +1,45 @@ + + + + + 17.0 + {086A7516-3492-4D13-8BD5-11123490C810} + Win32Proj + fuzz_renpy + 10.0 + Application + + + + + + + + + + fuzz-renpy + + + + zs.lib;%(AdditionalDependencies) + + + + + + $(IntDir)archive_fuzzer.obj + + + + + + + + + + + + + + + diff --git a/build/projects/fuzz-rpgmaker.vcxproj b/build/projects/fuzz-rpgmaker.vcxproj new file mode 100644 index 0000000..ea27dec --- /dev/null +++ b/build/projects/fuzz-rpgmaker.vcxproj @@ -0,0 +1,37 @@ + + + + + 17.0 + {DF60A28B-D6D4-4EF0-811B-4CC53ED93070} + Win32Proj + fuzz_rpgmaker + 10.0 + Application + + + + + + + + + + fuzz-rpgmaker + + + + + + $(IntDir)archive_fuzzer.obj + + + + + + + + + + + diff --git a/build/projects/fuzz-zanzarah.vcxproj b/build/projects/fuzz-zanzarah.vcxproj new file mode 100644 index 0000000..66615d9 --- /dev/null +++ b/build/projects/fuzz-zanzarah.vcxproj @@ -0,0 +1,37 @@ + + + + + 17.0 + {F56AF8F0-E552-41DB-85E9-8131548828E6} + Win32Proj + fuzz_zanzarah + 10.0 + Application + + + + + + + + + + fuzz-zanzarah + + + + + + $(IntDir)archive_fuzzer.obj + + + + + + + + + + + diff --git a/build/projects/leak-probe.vcxproj b/build/projects/leak-probe.vcxproj new file mode 100644 index 0000000..72b43dc --- /dev/null +++ b/build/projects/leak-probe.vcxproj @@ -0,0 +1,51 @@ + + + + + 17.0 + {3690B34C-A1CA-448D-A301-119156269054} + Win32Proj + leak_probe + 10.0 + Application + + + + + + + + + leak-probe + + + + MultiThreaded + + + zs.lib;%(AdditionalDependencies) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/projects/renpy.vcxproj b/build/projects/renpy.vcxproj new file mode 100644 index 0000000..1c2bab7 --- /dev/null +++ b/build/projects/renpy.vcxproj @@ -0,0 +1,48 @@ + + + + + 17.0 + {4E8AE1A2-12B2-4A87-9B28-62E3E7CDB510} + Win32Proj + renpy + 10.0 + DynamicLibrary + + + + + + + + + renpy + .so + + + + $(RepositoryRoot)src\modules\renpy\renpy.def + zsd.lib;%(AdditionalDependencies) + zs.lib;%(AdditionalDependencies) + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/projects/rpgmaker.vcxproj b/build/projects/rpgmaker.vcxproj new file mode 100644 index 0000000..148b648 --- /dev/null +++ b/build/projects/rpgmaker.vcxproj @@ -0,0 +1,43 @@ + + + + + 17.0 + {7DF16D34-0324-4AC4-B99F-637D2A7E53E8} + Win32Proj + rpgmaker + 10.0 + DynamicLibrary + + + + + + + + + rpgmaker + .so + + + + $(RepositoryRoot)src\modules\rpgmaker\rpgmaker.def + + + + + + + + + + + + + + + + + + + diff --git a/build/projects/tests.vcxproj b/build/projects/tests.vcxproj new file mode 100644 index 0000000..be209d5 --- /dev/null +++ b/build/projects/tests.vcxproj @@ -0,0 +1,69 @@ + + + + + 17.0 + {C074B2CD-C481-4C20-A349-902E02D39C6D} + Win32Proj + tests + 10.0 + Application + + + + + + + + + tests + debug\ + + + + $(VcpkgInstalledDir)$(VcpkgTriplet)\$(ObserverVcpkgDebugPrefix)lib\manual-link;%(AdditionalLibraryDirectories) + Catch2d.lib;xxhash.lib;zsd.lib;%(AdditionalDependencies) + Catch2.lib;xxhash.lib;zs.lib;%(AdditionalDependencies) + + + + + $(IntDir)bounded_stream_core.obj + + + + + + + + + + + + + $(IntDir)pickle_unit.obj + + + + $(IntDir)bounded_stream_unit.obj + + + $(IntDir)pickle_parser.obj + + + + + + + + + + + + + + + + + + diff --git a/build/projects/zanzarah.vcxproj b/build/projects/zanzarah.vcxproj new file mode 100644 index 0000000..c557929 --- /dev/null +++ b/build/projects/zanzarah.vcxproj @@ -0,0 +1,43 @@ + + + + + 17.0 + {B313D87B-B15E-4E0E-92F9-DA04231CC41A} + Win32Proj + zanzarah + 10.0 + DynamicLibrary + + + + + + + + + zanzarah + .so + + + + $(RepositoryRoot)src\modules\zanzarah\zanzarah.def + + + + + + + + + + + + + + + + + + + diff --git a/build/pyproject.toml b/build/pyproject.toml new file mode 100644 index 0000000..658a49f --- /dev/null +++ b/build/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "observer-build" +version = "0.1.0" +description = "Local content-addressed build graph for ObserverModules" +requires-python = "==3.14.6" +dependencies = [ + "coverage==7.15.2", + "filelock==3.32.2", + "jinja2==3.1.6", + "psutil==7.2.2", + "pywin32==312; sys_platform == 'win32'", +] + +[tool.uv] +package = false + +[tool.coverage.run] +branch = true +command_line = "-m unittest discover -s tests -p test_*.py" +source = ["."] + +[tool.coverage.report] +exclude_lines = [] +fail_under = 100 +include = ["core/*", "graphs/*", "driver.py", "main.py"] +show_missing = true +skip_covered = false diff --git a/build/templates/_output.ps1 b/build/templates/_output.ps1 new file mode 100644 index 0000000..83a273f --- /dev/null +++ b/build/templates/_output.ps1 @@ -0,0 +1,5 @@ +{% macro require_output(relative_path, message, path_type='Leaf') -%} +if (-not (Test-Path -LiteralPath (Join-Path $outDir {{ relative_path | ps_quote }}) -PathType {{ path_type }})) { + throw {{ message | ps_quote }} +} +{%- endmacro %} diff --git a/build/templates/analysis.ps1 b/build/templates/analysis.ps1 new file mode 100644 index 0000000..d5e74b0 --- /dev/null +++ b/build/templates/analysis.ps1 @@ -0,0 +1,6 @@ +{% extends "selected-compile.ps1" %} +{% block compile_args %} + '/p:ObserverRunCodeAnalysis=true' + '/p:RunCodeAnalysis=true' +{% block analyzer_args required %}{% endblock %} +{% endblock %} diff --git a/build/templates/argv.json b/build/templates/argv.json new file mode 100644 index 0000000..1e10f71 --- /dev/null +++ b/build/templates/argv.json @@ -0,0 +1,3 @@ +{% extends "script.json" %} +{% block script_exec %}{{ argv | json }}{% endblock %} +{% block script_body %}{% endblock %} diff --git a/build/templates/base.json b/build/templates/base.json new file mode 100644 index 0000000..5855f42 --- /dev/null +++ b/build/templates/base.json @@ -0,0 +1,7 @@ +{ + "name": {{ name | json }}, + "pool": {{ pool | json }}, + "inputs": {{ inputs | json }}, + "results": {{ results | json }}, + "script": {% block script %}null{% endblock %} +} diff --git a/build/templates/catch2-test.ps1 b/build/templates/catch2-test.ps1 new file mode 100644 index 0000000..654f675 --- /dev/null +++ b/build/templates/catch2-test.ps1 @@ -0,0 +1,26 @@ +{% extends "pwsh.ps1" %}{% from "_output.ps1" import require_output %} +{% block pwsh_body %} +{% for artifact in artifacts %}Copy-Item -LiteralPath {{ artifact.source | ps_quote }} -Destination (Join-Path $outDir {{ artifact.name | ps_quote }}) +{% endfor %} +{% block catch2_setup %}{% endblock %}Push-Location $outDir +try { + Invoke-Checked (Join-Path $outDir 'tests.exe') @( +{% block test_filter %}{% endblock %} + '--reporter' + 'compact' + '--reporter' + 'JUnit::out=tests.xml' + '--durations' + 'yes' + '--order' + 'lex' + '--shard-count' + {{ shard_count | string | ps_quote }} + '--shard-index' + {{ shard_index | string | ps_quote }} + ) +} finally { + Pop-Location +} +{% block catch2_post %}{{ require_output('tests.xml', 'Catch2 did not produce tests.xml') }} +{% endblock %}{% endblock %} diff --git a/build/templates/clang-command.ps1 b/build/templates/clang-command.ps1 new file mode 100644 index 0000000..e62748c --- /dev/null +++ b/build/templates/clang-command.ps1 @@ -0,0 +1,8 @@ +{% extends "selected-compile.ps1" %}{% from "_output.ps1" import require_output %} +{% block compile_args %} + "/p:ObserverClangCommandPath=$outDir\compile-command.json" + {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} +{% endblock %} +{% block post_msbuild %} +{{ require_output('compile-command.json', 'clang-cl did not produce compile-command.json') }} +{% endblock %} diff --git a/build/templates/clang-format.ps1 b/build/templates/clang-format.ps1 new file mode 100644 index 0000000..d5dd9cc --- /dev/null +++ b/build/templates/clang-format.ps1 @@ -0,0 +1,8 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +Invoke-Checked {{ clang_format | ps_quote }} @( + '--dry-run' + '--Werror' + {{ source | ps_quote }} +) +{% endblock %} diff --git a/build/templates/clang-tidy.ps1 b/build/templates/clang-tidy.ps1 new file mode 100644 index 0000000..4db324c --- /dev/null +++ b/build/templates/clang-tidy.ps1 @@ -0,0 +1,13 @@ +{% extends "analysis.ps1" %}{% from "_output.ps1" import require_output %} +{% block int_dir %} + "/p:IntDir=$outDir\obj\" +{% endblock %} +{% block analyzer_args %} + '/p:EnableMicrosoftCodeAnalysis=false' + '/p:ObserverEnableClangTidy=true' + {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} + {{ ('/p:ClangTidyLogFile=' ~ project_name ~ '.ClangTidy.log') | ps_quote }} +{% endblock %} +{% block post_msbuild %} +{{ require_output('obj\\' ~ project_name ~ '.ClangTidy.log', 'clang-tidy did not produce ' ~ project_name ~ '.ClangTidy.log') }} +{% endblock %} diff --git a/build/templates/contract.ps1 b/build/templates/contract.ps1 new file mode 100644 index 0000000..214e94e --- /dev/null +++ b/build/templates/contract.ps1 @@ -0,0 +1,4 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +& {{ test | ps_quote }} +{% endblock %} diff --git a/build/templates/coverage-corpus-test.ps1 b/build/templates/coverage-corpus-test.ps1 new file mode 100644 index 0000000..b72d6ff --- /dev/null +++ b/build/templates/coverage-corpus-test.ps1 @@ -0,0 +1,3 @@ +{% extends "coverage-test.ps1" %} +{% block test_filter %} '[compatibility]' +{% endblock %} diff --git a/build/templates/coverage-merge.ps1 b/build/templates/coverage-merge.ps1 new file mode 100644 index 0000000..5a98d6d --- /dev/null +++ b/build/templates/coverage-merge.ps1 @@ -0,0 +1,11 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +$profiles = @( +{% for directory in profile_directories %} Get-ChildItem -LiteralPath {{ directory | ps_quote }} -File -Filter '*.profraw' +{% endfor %}) | Sort-Object FullName | ForEach-Object FullName +if ($profiles.Count -eq 0) { + throw 'Coverage shards produced no LLVM raw profiles' +} +$arguments = @('merge', '-sparse') + $profiles + @('-o', (Join-Path $outDir 'coverage.profdata')) +Invoke-Checked {{ llvm_profdata | ps_quote }} $arguments +{% endblock %} diff --git a/build/templates/coverage-report.ps1 b/build/templates/coverage-report.ps1 new file mode 100644 index 0000000..d3d5c75 --- /dev/null +++ b/build/templates/coverage-report.ps1 @@ -0,0 +1,24 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +$arguments = @( + 'export' + {{ test_executable | ps_quote }} + '--instr-profile' + {{ profile | ps_quote }} + '--ignore-filename-regex' + {{ ignore_regex | ps_quote }} +{% for object in objects %} '--object' + {{ object | ps_quote }} +{% endfor %}{% if summary_only %} '--summary-only' +{% else %} '--format=lcov' +{% endif %}) +$content = & {{ llvm_cov | ps_quote }} @arguments +if ($LASTEXITCODE -ne 0) { + throw "llvm-cov failed with exit code $LASTEXITCODE" +} +$text = @($content | Where-Object { $_ -notmatch '^warning:' }) -join "`n" +if ([string]::IsNullOrWhiteSpace($text)) { + throw 'llvm-cov produced an empty report' +} +[IO.File]::WriteAllText((Join-Path $outDir {{ report_name | ps_quote }}), $text, [Text.UTF8Encoding]::new($false)) +{% endblock %} diff --git a/build/templates/coverage-test.ps1 b/build/templates/coverage-test.ps1 new file mode 100644 index 0000000..1fbfe77 --- /dev/null +++ b/build/templates/coverage-test.ps1 @@ -0,0 +1,7 @@ +{% extends "catch2-test.ps1" %} +{% block catch2_setup %}$env:LLVM_PROFILE_FILE = Join-Path $outDir 'coverage-%m-%p.profraw' +{% endblock %} +{% block catch2_post %}if (-not (Get-ChildItem -LiteralPath $outDir -File -Filter '*.profraw')) { + throw 'Instrumented Catch2 shard produced no LLVM raw profiles' +} +{% endblock %} diff --git a/build/templates/cppcheck.ps1 b/build/templates/cppcheck.ps1 new file mode 100644 index 0000000..e015bbe --- /dev/null +++ b/build/templates/cppcheck.ps1 @@ -0,0 +1,41 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +if (-not (Test-Path -LiteralPath {{ include_dir | ps_quote }} -PathType Container)) { + throw 'Cppcheck dependency headers were not restored' +} +Invoke-Checked {{ cppcheck | ps_quote }} @( + {{ source_dir | ps_quote }} + '--std=c++23' + {{ ('--platform=' ~ platform) | ps_quote }} + '-DWIN32=1' + '-D_WIN32=1' + '-DUNICODE=1' + '-D_UNICODE=1' + {{ ('-D' ~ architecture_define) | ps_quote }} + {{ ('-I' ~ source_dir) | ps_quote }} + {{ ('-I' ~ include_dir) | ps_quote }} + '--enable=warning,style,performance,portability' + '--check-level=exhaustive' + '--inconclusive' + '--inline-suppr' + '--suppress=missingIncludeSystem' + '--suppress=uninitMemberVarNoCtor:src/api.h' + {{ ('--suppress=*:*\\' ~ triplet ~ '\\include\\*') | ps_quote }} + '--suppress=functionStatic' + {{ ('--relative-paths=' ~ repository) | ps_quote }} + '--output-format=sarif' + "--output-file=$outDir\cppcheck.sarif" + "--cppcheck-build-dir=$buildDir" +) +$reportPath = Join-Path $outDir 'cppcheck.sarif' +if (-not (Test-Path -LiteralPath $reportPath -PathType Leaf)) { + throw 'Cppcheck did not produce cppcheck.sarif' +} +$report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json +$index = 0 +foreach ($run in @($report.runs)) { + $run | Add-Member -NotePropertyName automationDetails -NotePropertyValue ([ordered]@{ id = {{ automation_id | ps_quote }} + "$index/" }) -Force + ++$index +} +$report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $reportPath -Encoding utf8 +{% endblock %} diff --git a/build/templates/fuzz-build.ps1 b/build/templates/fuzz-build.ps1 new file mode 100644 index 0000000..3fcbeb6 --- /dev/null +++ b/build/templates/fuzz-build.ps1 @@ -0,0 +1,9 @@ +{% extends "msbuild.ps1" %}{% from "_output.ps1" import require_output %} +{% block msbuild_args %} + {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }} + {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }} + {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} + {{ ('/p:LLVMRuntimeDir=' ~ llvm_runtime) | ps_quote }} +{% endblock %} +{% block post_msbuild %}{{ require_output(executable_name, 'MSBuild did not produce the fuzzer executable') }} +{% endblock %} diff --git a/build/templates/fuzz-common.ps1 b/build/templates/fuzz-common.ps1 new file mode 100644 index 0000000..cea037b --- /dev/null +++ b/build/templates/fuzz-common.ps1 @@ -0,0 +1,36 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +$fuzzer = {{ fuzzer | ps_quote }} +$seedDir = {{ seed_dir | ps_quote }} +$corpus = Join-Path $buildDir 'corpus' +$artifactDir = Join-Path $outDir 'artifacts' +if (-not (Test-Path -LiteralPath $fuzzer -PathType Leaf)) { throw 'Fuzzer executable was not found' } +if (-not (Test-Path -LiteralPath $seedDir -PathType Container)) { throw 'Fuzzer seed directory was not found' } +[void](New-Item -ItemType Directory -Path $corpus) +[void](New-Item -ItemType Directory -Path $artifactDir) +$seeds = @(Get-ChildItem -LiteralPath $seedDir -File | Sort-Object Name) +if ($seeds.Count -eq 0) { throw 'No checked-in fuzzer seeds were found' } +foreach ($seed in $seeds) { + if ($seed.Extension -eq '.hex') { + $hex = (Get-Content -LiteralPath $seed.FullName -Raw) -replace '\s', '' + if ($hex.Length -eq 0 -or $hex.Length % 2 -ne 0 -or $hex -notmatch '^[0-9A-Fa-f]+$') { + throw "Invalid hexadecimal fuzzer seed: $($seed.FullName)" + } + [IO.File]::WriteAllBytes((Join-Path $corpus $seed.BaseName), [Convert]::FromHexString($hex)) + } else { + Copy-Item -LiteralPath $seed.FullName -Destination $corpus + } +} +{% if prior_corpus is defined and prior_corpus %}$priorCorpus = {{ prior_corpus | ps_quote }} +if (-not (Test-Path -LiteralPath $priorCorpus -PathType Container)) { throw 'Prior fuzzer corpus was not found' } +Get-ChildItem -LiteralPath $priorCorpus -File | Sort-Object Name | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $corpus -Force +} +{% endif %}Push-Location (Split-Path -Parent $fuzzer) +try { +{% block fuzz_invoke required %}{% endblock %} +} finally { + Pop-Location +} +{% block fuzz_post %}{% endblock %} +{% endblock %} diff --git a/build/templates/fuzz-gate.ps1 b/build/templates/fuzz-gate.ps1 new file mode 100644 index 0000000..1d3921a --- /dev/null +++ b/build/templates/fuzz-gate.ps1 @@ -0,0 +1,10 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +$status = {{ status | ps_quote }} +if (-not (Test-Path -LiteralPath $status -PathType Leaf)) { throw 'Fuzzer status was not found' } +$exitCode = 0 +if (-not [int]::TryParse((Get-Content -LiteralPath $status -Raw), [ref]$exitCode)) { + throw 'Fuzzer status is not an integer' +} +if ($exitCode -ne 0) { throw "Fuzzer exited with code $exitCode" } +{% endblock %} diff --git a/build/templates/fuzz-replay.ps1 b/build/templates/fuzz-replay.ps1 new file mode 100644 index 0000000..dbd3afe --- /dev/null +++ b/build/templates/fuzz-replay.ps1 @@ -0,0 +1,10 @@ +{% extends "fuzz-common.ps1" %} +{% block fuzz_invoke %}$inputs = @(Get-ChildItem -LiteralPath $corpus -File | Sort-Object Name) +Invoke-Checked $fuzzer (@($inputs.FullName) + @( + {{ ('-max_len=' ~ max_length) | ps_quote }} + '-rss_limit_mb=1024' + '-timeout=10' + '-print_final_stats=1' + "-artifact_prefix=$artifactDir\" +)) +{% endblock %} diff --git a/build/templates/fuzz-run.ps1 b/build/templates/fuzz-run.ps1 new file mode 100644 index 0000000..73d6b1c --- /dev/null +++ b/build/templates/fuzz-run.ps1 @@ -0,0 +1,21 @@ +{% extends "fuzz-common.ps1" %} +{% block fuzz_invoke %}$PSNativeCommandUseErrorActionPreference = $false +& $fuzzer @( + $corpus + {{ ('-max_total_time=' ~ seconds) | ps_quote }} + {{ ('-max_len=' ~ max_length) | ps_quote }} + '-rss_limit_mb=1024' + '-timeout=10' + '-use_value_profile=1' + '-print_final_stats=1' + "-artifact_prefix=$artifactDir\" +) +$fuzzExitCode = $LASTEXITCODE +{% endblock %} +{% block fuzz_post %}$publishedCorpus = Join-Path $outDir 'corpus' +[void](New-Item -ItemType Directory -Path $publishedCorpus) +Get-ChildItem -LiteralPath $corpus -File | Sort-Object Name | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $publishedCorpus +} +[IO.File]::WriteAllText((Join-Path $outDir 'status.txt'), [string]$fuzzExitCode) +{% endblock %} diff --git a/build/templates/instrumented-build.ps1 b/build/templates/instrumented-build.ps1 new file mode 100644 index 0000000..7fe1480 --- /dev/null +++ b/build/templates/instrumented-build.ps1 @@ -0,0 +1,8 @@ +{% extends "msbuild.ps1" %} +{% block msbuild_args %} + {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }} + '/p:VcpkgManifestInstall=false' + {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }} +{% if llvm_dir %} {{ ('/p:LLVMInstallDir=' ~ llvm_dir) | ps_quote }} +{% endif %}{% if llvm_runtime %} {{ ('/p:LLVMRuntimeDir=' ~ llvm_runtime) | ps_quote }} +{% endif %}{% endblock %} diff --git a/build/templates/msbuild.ps1 b/build/templates/msbuild.ps1 new file mode 100644 index 0000000..22a6142 --- /dev/null +++ b/build/templates/msbuild.ps1 @@ -0,0 +1,18 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +{% block pwsh_setup %}{% endblock %}Invoke-Checked {{ msbuild | ps_quote }} @( + {{ project | ps_quote }} + '/nologo' + '/m:1' + '/nr:false' + {{ ('/t:' ~ target) | ps_quote }} + '/p:BuildProjectReferences=false' + {{ ('/p:Configuration=' ~ configuration) | ps_quote }} + {{ ('/p:Platform=' ~ platform) | ps_quote }} + "/p:OutDir=$outDir\" +{% block int_dir %} "/p:IntDir=$buildDir\"{{ '\n' }}{% endblock %} +{% block msbuild_args %}{% for argument in msbuild_args %} + {{ argument | ps_quote }} +{% endfor %}{% endblock %} +){{ '\n' }}{% block post_msbuild %}{% endblock %} +{% endblock %} diff --git a/build/templates/msvc-analyze.ps1 b/build/templates/msvc-analyze.ps1 new file mode 100644 index 0000000..92f3d0f --- /dev/null +++ b/build/templates/msvc-analyze.ps1 @@ -0,0 +1,10 @@ +{% extends "analysis.ps1" %}{% from "_output.ps1" import require_output %} +{% block analyzer_args %} + '/p:EnableMicrosoftCodeAnalysis=true' + '/p:ObserverEnableClangTidy=false' + {{ ('/p:ObserverAnalysisReportName=' ~ project_name) | ps_quote }} + "/p:ObserverAnalysisReportPath=$outDir\{{ project_name }}.sarif" +{% endblock %} +{% block post_msbuild %} +{{ require_output(project_name ~ '.sarif', 'MSVC analysis did not produce ' ~ project_name ~ '.sarif') }} +{% endblock %} diff --git a/build/templates/native-build.ps1 b/build/templates/native-build.ps1 new file mode 100644 index 0000000..af24b02 --- /dev/null +++ b/build/templates/native-build.ps1 @@ -0,0 +1,10 @@ +{% extends "msbuild.ps1" %} +{% block msbuild_args %} + {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }} + '/p:VcpkgManifestInstall=false' +{% if vcpkg_installed %} + {{ ('/p:VcpkgInstalledDir=' ~ vcpkg_installed ~ '\\') | ps_quote }} +{% else %} + "/p:VcpkgInstalledDir=$buildDir\vcpkg\" +{% endif %} +{% endblock %} diff --git a/build/templates/native-corpus-test.ps1 b/build/templates/native-corpus-test.ps1 new file mode 100644 index 0000000..fbf2795 --- /dev/null +++ b/build/templates/native-corpus-test.ps1 @@ -0,0 +1,3 @@ +{% extends "native-test.ps1" %} +{% block test_filter %} '[compatibility]' +{% endblock %} diff --git a/build/templates/native-test.ps1 b/build/templates/native-test.ps1 new file mode 100644 index 0000000..4b03140 --- /dev/null +++ b/build/templates/native-test.ps1 @@ -0,0 +1 @@ +{% extends "catch2-test.ps1" %} diff --git a/build/templates/psscriptanalyzer.ps1 b/build/templates/psscriptanalyzer.ps1 new file mode 100644 index 0000000..206118e --- /dev/null +++ b/build/templates/psscriptanalyzer.ps1 @@ -0,0 +1,27 @@ +{% extends "pwsh.ps1" %} +{% block pwsh_body %} +Import-Module {{ psscriptanalyzer | ps_quote }} +$diagnostics = @(Invoke-ScriptAnalyzer -Path {{ source | ps_quote }} -Settings {{ settings | ps_quote }}) +$results = @($diagnostics | ForEach-Object { + $level = switch ($_.Severity.ToString()) { 'Error' { 'error' } 'Warning' { 'warning' } default { 'note' } } + [ordered]@{ + ruleId = $_.RuleName + level = $level + message = [ordered]@{ text = $_.Message } + locations = @([ordered]@{ physicalLocation = [ordered]@{ + artifactLocation = [ordered]@{ uri = [IO.Path]::GetRelativePath({{ repository | ps_quote }}, $_.ScriptPath).Replace('\', '/') } + region = [ordered]@{ startLine = [int]$_.Line; startColumn = [int]$_.Column } + }}) + } +}) +$sarif = [ordered]@{ + version = '2.1.0' + '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' + runs = @([ordered]@{ + automationDetails = [ordered]@{ id = {{ automation_id | ps_quote }} } + tool = [ordered]@{ driver = [ordered]@{ name = 'PSScriptAnalyzer' } } + results = $results + }) +} +$sarif | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath "$outDir\psscriptanalyzer.sarif" -Encoding utf8 +{% endblock %} diff --git a/build/templates/pwsh.ps1 b/build/templates/pwsh.ps1 new file mode 100644 index 0000000..c5426e8 --- /dev/null +++ b/build/templates/pwsh.ps1 @@ -0,0 +1,30 @@ +{% extends "script.json" %} + +{% block script_exec %} +[{{ pwsh | json }},"-NoLogo","-NoProfile","-NonInteractive","-Command","$ErrorActionPreference = 'Stop'; & ([ScriptBlock]::Create([Console]::In.ReadToEnd()))"] +{% endblock %} + +{% block script_body %} +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$outDir = $env:OBSERVER_OUT_DIR +$buildDir = $env:OBSERVER_BUILD_DIR +if ([string]::IsNullOrWhiteSpace($outDir) -or [string]::IsNullOrWhiteSpace($buildDir)) { + throw 'OBSERVER_OUT_DIR and OBSERVER_BUILD_DIR are required' +} + +function Invoke-Checked { + param( + [Parameter(Mandatory)][string] $FilePath, + [Parameter()][string[]] $ArgumentList = @() + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath" + } +} + +{% block pwsh_body required %}{% endblock %} +{% endblock %} diff --git a/build/templates/sanitizer-test.ps1 b/build/templates/sanitizer-test.ps1 new file mode 100644 index 0000000..0dc2264 --- /dev/null +++ b/build/templates/sanitizer-test.ps1 @@ -0,0 +1,6 @@ +{% extends "catch2-test.ps1" %}{% from "_output.ps1" import require_output %} +{% block catch2_setup %}{% if runtime %}Copy-Item -LiteralPath {{ runtime.source | ps_quote }} -Destination (Join-Path $outDir {{ runtime.name | ps_quote }}) +{% endif %}$env:{{ options_name }} = {{ options_value | ps_quote }} +{% endblock %} +{% block catch2_post %}{{ require_output('tests.xml', 'Sanitizer Catch2 shard did not produce tests.xml') }} +{% endblock %} diff --git a/build/templates/script.json b/build/templates/script.json new file mode 100644 index 0000000..65d691b --- /dev/null +++ b/build/templates/script.json @@ -0,0 +1,8 @@ +{% extends "base.json" %} + +{% block script %} +{ + "exec": {{ self.script_exec() | trim }}, + "data": {{ self.script_body() | trim | json }} +} +{% endblock %} diff --git a/build/templates/selected-compile.ps1 b/build/templates/selected-compile.ps1 new file mode 100644 index 0000000..5c774cd --- /dev/null +++ b/build/templates/selected-compile.ps1 @@ -0,0 +1,16 @@ +{% extends "msbuild.ps1" %} +{% block pwsh_setup %} +{% if vcpkg_installed %}$vcpkgInstalledDir = {{ vcpkg_installed | ps_quote }} +{% else %}$vcpkgInstalledDir = Join-Path $buildDir 'vcpkg' +[void](New-Item -ItemType Directory -Force -Path $vcpkgInstalledDir) +{% endif %}{% endblock %} +{% block msbuild_args %} + '/p:ObserverCompileAnalysis=true' + '/p:ForceRebuild=true' + {{ ('/p:SelectedFiles=' ~ source) | ps_quote }} + '/p:SelectedFilesBuildPCH=false' + '/p:SelectedFilesBuildModules=false' + {{ ('/p:VcpkgRoot=' ~ vcpkg_root) | ps_quote }} + "/p:VcpkgInstalledDir=$vcpkgInstalledDir\" +{% block compile_args required %}{% endblock %} +{% endblock %} diff --git a/build/templates/source-dependencies.ps1 b/build/templates/source-dependencies.ps1 new file mode 100644 index 0000000..5f4f01c --- /dev/null +++ b/build/templates/source-dependencies.ps1 @@ -0,0 +1,7 @@ +{% extends "selected-compile.ps1" %}{% from "_output.ps1" import require_output %} +{% block compile_args %} + "/p:ObserverSourceDependenciesPath=$outDir\dependencies.json" +{% endblock %} +{% block post_msbuild %} +{{ require_output('dependencies.json', 'MSVC did not produce dependencies.json') }} +{% endblock %} diff --git a/build/templates/vcpkg.ps1 b/build/templates/vcpkg.ps1 new file mode 100644 index 0000000..82a606c --- /dev/null +++ b/build/templates/vcpkg.ps1 @@ -0,0 +1,14 @@ +{% extends "pwsh.ps1" %}{% from "_output.ps1" import require_output %} +{% block pwsh_body %} +Invoke-Checked {{ vcpkg | ps_quote }} @( + 'install' + "--x-install-root=$outDir" + "--x-buildtrees-root=$buildDir\b" + "--x-packages-root=$buildDir\p" + '--triplet' + {{ triplet | ps_quote }} + {{ ('--x-manifest-root=' ~ repository) | ps_quote }} + {{ ('--overlay-triplets=' ~ repository ~ '\\build\\vcpkg\\triplets') | ps_quote }} +) +{{ require_output(triplet ~ '\\include', 'vcpkg restore did not produce the include directory', 'Container') }} +{% endblock %} diff --git a/build/tests/security-mitigation-contract.Tests.ps1 b/build/tests/security-mitigation-contract.Tests.ps1 new file mode 100644 index 0000000..0eaa4f1 --- /dev/null +++ b/build/tests/security-mitigation-contract.Tests.ps1 @@ -0,0 +1,36 @@ +#requires -Version 7.4 + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$projectPropertiesPath = Join-Path $repositoryRoot 'build\ObserverProject.props' +[xml]$projectProperties = Get-Content -LiteralPath $projectPropertiesPath -Raw +$namespace = [System.Xml.XmlNamespaceManager]::new($projectProperties.NameTable) +$namespace.AddNamespace('msb', 'http://schemas.microsoft.com/developer/msbuild/2003') + +$cetCompat = $projectProperties.SelectSingleNode( + '/msb:Project/msb:ItemDefinitionGroup/msb:Link/msb:CETCompat', + $namespace +) +if ($null -eq $cetCompat -or $cetCompat.InnerText -ne 'true') { + throw 'Release CET compatibility must remain enabled for supported targets.' +} +$expectedCondition = "'`$(Configuration)' == 'Release' And '`$(Platform)' != 'ARM64'" +if ($cetCompat.Condition -ne $expectedCondition) { + throw "CET compatibility must exclude only ARM64; actual condition: '$($cetCompat.Condition)'." +} + +$linkControlFlowGuard = $projectProperties.SelectSingleNode( + '/msb:Project/msb:ItemDefinitionGroup/msb:Link/msb:ControlFlowGuard', + $namespace +) +if ( + $null -eq $linkControlFlowGuard -or + $linkControlFlowGuard.InnerText -ne 'Guard' -or + $linkControlFlowGuard.Condition -ne "'`$(Configuration)' == 'Release'" +) { + throw 'Control Flow Guard must remain enabled for every Release architecture, including ARM64.' +} + +Write-Host '[OK] Release CET is scoped to supported targets without weakening CFG.' diff --git a/build/tests/test_analysis_graph.py b/build/tests/test_analysis_graph.py new file mode 100644 index 0000000..543e842 --- /dev/null +++ b/build/tests/test_analysis_graph.py @@ -0,0 +1,660 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.paths import BuildPaths # noqa: E402 +import graphs.analysis as analysis # noqa: E402 +from graphs.analysis import ( # noqa: E402 + analysis_discovery_slice, + analysis_slice, + load_dependency_manifests, +) + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + llvm_dir: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +class AnalysisSliceTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + project = """ + + + +""" + files = { + "src/modules/renpy/pickle.cpp": '#include "pickle.h"\n', + "src/modules/renpy/pickle.h": "#pragma once\n", + "src/modules/rpgmaker/rpgmaker.cpp": "#include \n", + "build/projects/renpy.vcxproj": project.format( + source="src/modules/renpy/pickle.cpp" + ), + "build/projects/rpgmaker.vcxproj": project.format( + source="src/modules/rpgmaker/rpgmaker.cpp" + ), + "build/ObserverProjectConfigurations.props": "\n", + "build/ObserverConfiguration.props": "\n", + "build/ObserverProject.props": "\n", + "build/ObserverNativeAnalysis.ruleset": "\n", + ".clang-tidy": "Checks: bugprone-*\n", + "vcpkg.json": '{"dependencies":["zlib"]}\n', + "build/vcpkg/triplets/observer-x64-windows-static.cmake": ( + "set(VCPKG_LIBRARY_LINKAGE static)\n" + ), + "build/vcpkg/triplets/observer-x86-windows-static.cmake": ( + "set(VCPKG_LIBRARY_LINKAGE static)\n" + ), + } + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def toolchain(self, root: Path) -> FakeToolchain: + tools = root / "fake tools" + tools.mkdir() + paths = { + "msbuild": tools / "MSBuild.exe", + "pwsh": tools / "pwsh.exe", + "vcpkg_root": tools / "vcpkg", + "llvm_dir": tools / "llvm", + } + paths["vcpkg_root"].mkdir() + (paths["vcpkg_root"] / "vcpkg.exe").touch() + paths["llvm_dir"].mkdir() + paths["msbuild"].touch() + paths["pwsh"].touch() + return FakeToolchain( + **paths, + environment=( + ("PATH", str(tools)), + ("VCPKG_ROOT", str(tools / "vcpkg-current")), + ), + identity={"msbuild": "17.14", "msvc": "14.44", "llvm": "19.1.5"}, + ) + + @staticmethod + def manifest(source: Path, *includes: Path) -> bytes: + return json.dumps( + { + "Version": "1.2", + "Data": { + "Source": str(source.resolve()), + "ProvidedModule": "", + "ImportedModules": [], + "Includes": [str(path.resolve()) for path in includes], + }, + }, + separators=(",", ":"), + ).encode() + + def test_project_inventory_parses_each_project_once_and_preserves_requested_order( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = self.repository(Path(temporary) / "repo") + with mock.patch.object( + analysis.ET, "parse", wraps=analysis.ET.parse + ) as parse: + projects = analysis.project_inventory( + repository, ("rpgmaker", "renpy") + ) + + self.assertEqual(parse.call_count, 2) + self.assertEqual(tuple(project.name for project in projects), ("rpgmaker", "renpy")) + self.assertEqual( + projects[0].inputs, + ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", + "build/projects/rpgmaker.vcxproj", + ), + ) + self.assertEqual( + projects[0].sources, + (repository / "src/modules/rpgmaker/rpgmaker.cpp",), + ) + + def staged_graphs( + self, repository: Path, toolchain: FakeToolchain, *, jobs: int = 2, + architectures: tuple[str, ...] = ("x64",), + ): + discovery = analysis_discovery_slice( + repository, toolchain, jobs=jobs, architectures=architectures + ) + paths = BuildPaths(repository) + manifests = {} + for target in discovery.targets: + source = repository / ( + "src/modules/renpy/pickle.cpp" + if target.endswith("modules.renpy.pickle") + else "src/modules/rpgmaker/rpgmaker.cpp" + ) + includes = ( + (repository / "src/modules/renpy/pickle.h",) + if target.endswith("modules.renpy.pickle") + else () + ) + if "-rpgmaker-" in target: + architecture = target.removeprefix("discover-dependencies-").split("-", 1)[0] + restore = discovery.node(f"restore-vcpkg-{architecture}") + package = paths.cas(restore.uid).output / "include/zlib.h" + package.parent.mkdir(parents=True, exist_ok=True) + if not package.exists(): + package.write_text("#pragma once\n", encoding="utf-8") + includes = (package,) + manifests[target] = self.manifest(source, *includes) + graph = analysis_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + jobs=jobs, + architectures=architectures, + ) + return discovery, graph + + def test_two_analysis_backends_are_independent_demand_targets(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.staged_graphs(repository, self.toolchain(root)) + + self.assertEqual( + graph.targets, + ("analysis-x64",), + ) + merged = graph.node("merge-analysis-x64") + self.assertEqual( + tuple( + (result.id, result.kind, result.media_type, result.relative_path) + for result in merged.results + ), + (( + "reports/sarif/x64/analysis.sarif", + "sarif", + "application/sarif+json", + "analysis.sarif", + ),), + ) + self.assertEqual(graph.node("analysis-x64").results, ()) + self.assertTrue(all( + not node.results + for node in graph.nodes + if node.name.startswith(("restore-", "discover-", "analyze-", "normalize-")) + )) + self.assertEqual(graph.pools, {"misc": 2, "restore": 1, "slot": 2}) + self.assertEqual( + tuple(node.name for node in graph.nodes), + ( + "restore-vcpkg-x64", + "discover-dependencies-x64-renpy-modules.renpy.pickle", + "discover-dependencies-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "analyze-msvc-x64-renpy-modules.renpy.pickle", + "analyze-tidy-x64-renpy-modules.renpy.pickle", + "normalize-msvc-x64-renpy-modules.renpy.pickle", + "normalize-tidy-x64-renpy-modules.renpy.pickle", + "analyze-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "analyze-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "normalize-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "normalize-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "merge-analysis-x64", + "analysis-x64", + ), + ) + restore = graph.nodes[0] + self.assertEqual(dict(restore.command.env)["VCPKG_ROOT"], str(root / "fake tools/vcpkg")) + raw = (graph.nodes[3], graph.nodes[4], graph.nodes[7], graph.nodes[8]) + for node in raw: + self.assertEqual(node.pool, "slot") + self.assertEqual(node.command.cwd, str(repository.resolve())) + self.assertEqual(dict(node.command.env)["PATH"], str(root / "fake tools")) + script = node.command.stdin.decode("utf-8") + self.assertIn("'/t:ClCompile'", script) + self.assertIn("'/p:Configuration=Debug'", script) + self.assertIn("'/p:Platform=x64'", script) + self.assertIn("'/p:SelectedFiles=", script) + self.assertIn("'/p:VcpkgRoot=", script) + self.assertEqual(raw[0].inputs, (discovery.targets[0],)) + self.assertEqual(raw[1].inputs, (discovery.targets[0],)) + self.assertEqual(raw[2].inputs, (discovery.targets[1],)) + self.assertEqual(raw[3].inputs, (discovery.targets[1],)) + msvc = raw[0].command.stdin.decode("utf-8") + tidy = raw[1].command.stdin.decode("utf-8") + self.assertIn('"/p:ObserverAnalysisReportPath=$outDir\\renpy.sarif"', msvc) + self.assertIn('"/p:IntDir=$buildDir\\"', msvc) + self.assertIn("MSVC analysis did not produce renpy.sarif", msvc) + self.assertIn(")\nif (-not (Test-Path", msvc) + self.assertIn('"/p:IntDir=$outDir\\obj\\"', tidy) + self.assertIn("'/p:LLVMInstallDir=", tidy) + self.assertIn("clang-tidy did not produce renpy.ClangTidy.log", tidy) + self.assertIn(")\nif (-not (Test-Path", tidy) + normalize_msvc, normalize_tidy = graph.nodes[5:7] + rpg_normalize_msvc, rpg_normalize_tidy, merge, gate = graph.nodes[9:] + self.assertEqual(normalize_msvc.inputs, (raw[0].name,)) + self.assertEqual(normalize_tidy.inputs, (raw[1].name,)) + self.assertEqual(rpg_normalize_msvc.inputs, (raw[2].name,)) + self.assertEqual(rpg_normalize_tidy.inputs, (raw[3].name,)) + self.assertEqual( + merge.inputs, + ( + normalize_msvc.name, + normalize_tidy.name, + rpg_normalize_msvc.name, + rpg_normalize_tidy.name, + ), + ) + self.assertEqual(gate.inputs, (merge.name,)) + self.assertEqual(normalize_msvc.command.argv[1:4], ("-m", "core.sarif", "normalize-msvc")) + self.assertEqual(normalize_tidy.command.argv[1:4], ("-m", "core.sarif", "convert-tidy")) + self.assertEqual(merge.command.argv[1:4], ("-m", "core.sarif", "merge")) + self.assertEqual(gate.command.argv[1:4], ("-m", "core.sarif", "gate")) + self.assertTrue( + all(node.pool == "misc" for node in graph.nodes[5:7] + graph.nodes[9:]) + ) + + def test_tidy_configuration_invalidates_only_tidy_node(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _before_discovery, before = self.staged_graphs(repository, toolchain) + (repository / ".clang-tidy").write_text( + "Checks: bugprone-*,performance-*\n", encoding="utf-8" + ) + _after_discovery, after = self.staged_graphs(repository, toolchain) + + unchanged = ( + "restore-vcpkg-x64", + "analyze-msvc-x64-renpy-modules.renpy.pickle", + "normalize-msvc-x64-renpy-modules.renpy.pickle", + "analyze-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "normalize-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + ) + changed = ( + "analyze-tidy-x64-renpy-modules.renpy.pickle", + "normalize-tidy-x64-renpy-modules.renpy.pickle", + "analyze-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "normalize-tidy-x64-rpgmaker-modules.rpgmaker.rpgmaker", + "merge-analysis-x64", + "analysis-x64", + ) + for name in unchanged: + self.assertEqual(before.node(name).uid, after.node(name).uid) + for name in changed: + self.assertNotEqual(before.node(name).uid, after.node(name).uid) + + def test_dynamic_include_forms_are_deferred_to_msvc_dependency_discovery(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + forms = ( + "#define HEADER \"pickle.h\"\n#include HEADER\n", + '#include_next "pickle.h"\n', + '#if __has_include("pickle.h")\n#include "pickle.h"\n#endif\n', + ) + for source in forms: + with self.subTest(source=source): + (repository / "src/modules/renpy/pickle.cpp").write_text( + source, encoding="utf-8" + ) + graph = analysis_discovery_slice(repository, toolchain) + + discovery = graph.node( + "discover-dependencies-x64-renpy-modules.renpy.pickle" + ) + script = discovery.command.stdin.decode("utf-8") + self.assertIn("/p:ObserverSourceDependenciesPath=", script) + self.assertIn("dependencies.json", script) + self.assertNotIn("ObserverRunCodeAnalysis", script) + + def test_architecture_selects_distinct_platform_triplet_and_target(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.staged_graphs( + repository, self.toolchain(root), architectures=("x86",) + ) + + self.assertEqual(graph.targets, ("analysis-x86",)) + self.assertEqual(graph.nodes[0].name, "restore-vcpkg-x86") + self.assertIn("observer-x86-windows-static", graph.nodes[0].command.stdin.decode()) + raw_script = graph.nodes[3].command.stdin.decode() + self.assertIn("'/p:Platform=Win32'", raw_script) + self.assertIn("analyze-msvc-x86-renpy", graph.nodes[3].name) + + def test_phase_two_signs_only_compiler_reported_project_and_package_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + unrelated = repository / "src/unused.h" + unrelated.write_text("one\n", encoding="utf-8") + toolchain = self.toolchain(root) + discovery, baseline = self.staged_graphs(repository, toolchain) + unrelated.write_text("two\n", encoding="utf-8") + _discovery, unrelated_changed = self.staged_graphs(repository, toolchain) + (repository / "src/modules/renpy/pickle.h").write_text( + "#pragma once\n// changed\n", encoding="utf-8" + ) + _discovery, header_changed = self.staged_graphs(repository, toolchain) + restore = discovery.node("restore-vcpkg-x64") + package = BuildPaths(repository).cas(restore.uid).output + (package / "include/zlib.h").write_text("// changed\n", encoding="utf-8") + _discovery, package_changed = self.staged_graphs(repository, toolchain) + + names = { + "renpy": "analyze-msvc-x64-renpy-modules.renpy.pickle", + "rpgmaker": "analyze-msvc-x64-rpgmaker-modules.rpgmaker.rpgmaker", + } + for name in names.values(): + self.assertEqual(baseline.node(name).uid, unrelated_changed.node(name).uid) + self.assertNotEqual( + unrelated_changed.node(names["renpy"]).uid, + header_changed.node(names["renpy"]).uid, + ) + self.assertEqual( + unrelated_changed.node(names["rpgmaker"]).uid, + header_changed.node(names["rpgmaker"]).uid, + ) + self.assertNotEqual( + header_changed.node(names["rpgmaker"]).uid, + package_changed.node(names["rpgmaker"]).uid, + ) + + def test_cross_directory_first_party_dependency_must_be_declared(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + shared = repository / "src/shared/topology.h" + shared.parent.mkdir() + shared.write_text("#pragma once\n", encoding="utf-8") + toolchain = self.toolchain(root) + discovery = analysis_discovery_slice(repository, toolchain) + renpy = "discover-dependencies-x64-renpy-modules.renpy.pickle" + rpgmaker = "discover-dependencies-x64-rpgmaker-modules.rpgmaker.rpgmaker" + manifests = { + renpy: self.manifest( + repository / "src/modules/renpy/pickle.cpp", shared + ), + rpgmaker: self.manifest( + repository / "src/modules/rpgmaker/rpgmaker.cpp" + ), + } + + with self.assertRaisesRegex( + ValueError, "first-party dependency is not covered.*src/shared/topology.h" + ): + analysis_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + ) + + project = repository / "build/projects/renpy.vcxproj" + project.write_text( + project.read_text(encoding="utf-8").replace( + "", + " \n", + ), + encoding="utf-8", + ) + declared_discovery = analysis_discovery_slice(repository, toolchain) + declared = analysis_slice( + repository, + toolchain, + discovery=declared_discovery, + manifests=manifests, + ) + + self.assertIn("analysis-x64", declared.targets) + + def test_published_manifests_are_loaded_by_exact_discovery_uid(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery = analysis_discovery_slice(repository, self.toolchain(root)) + paths = BuildPaths(repository) + expected = {} + for target in discovery.targets: + node = discovery.node(target) + cas = paths.cas(node.uid) + cas.output.mkdir(parents=True) + source = repository / ( + "src/modules/renpy/pickle.cpp" + if target.endswith("modules.renpy.pickle") + else "src/modules/rpgmaker/rpgmaker.cpp" + ) + content = self.manifest(source) + (cas.output / "dependencies.json").write_bytes(content) + cas.touch.touch() + expected[target] = content + + loaded = load_dependency_manifests(repository, discovery) + + self.assertEqual(loaded, expected) + + def test_incomplete_dependency_discovery_is_not_loadable(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery = analysis_discovery_slice(repository, self.toolchain(root)) + + with self.assertRaisesRegex(FileNotFoundError, "dependency discovery is incomplete"): + load_dependency_manifests(repository, discovery) + + def test_cached_manifests_do_not_change_discovery_or_downstream_uids(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + cold = analysis_discovery_slice(repository, toolchain) + manifests = {} + for target in cold.targets: + source = repository / ( + "src/modules/renpy/pickle.cpp" + if target.endswith("modules.renpy.pickle") + else "src/modules/rpgmaker/rpgmaker.cpp" + ) + content = self.manifest(source) + manifests[target] = content + cas = BuildPaths(repository).cas(cold.node(target).uid) + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes(content) + cas.touch.touch() + + cold_downstream = analysis_slice( + repository, + toolchain, + discovery=cold, + manifests=manifests, + ) + warm = analysis_discovery_slice(repository, toolchain) + warm_downstream = analysis_slice( + repository, + toolchain, + discovery=warm, + manifests=manifests, + ) + + self.assertEqual( + {node.name: node.uid for node in cold.nodes}, + {node.name: node.uid for node in warm.nodes}, + ) + self.assertEqual( + {node.name: node.uid for node in cold_downstream.nodes}, + {node.name: node.uid for node in warm_downstream.nodes}, + ) + + def test_partial_cached_manifests_do_not_mix_discovery_generations(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + cold = analysis_discovery_slice(repository, toolchain) + target = cold.targets[0] + manifests = { + target: self.manifest( + repository / "src/modules/renpy/pickle.cpp", + repository / "src/modules/renpy/pickle.h", + ), + cold.targets[1]: self.manifest( + repository / "src/modules/rpgmaker/rpgmaker.cpp" + ), + } + cold_downstream = analysis_slice( + repository, + toolchain, + discovery=cold, + manifests=manifests, + ) + cas = BuildPaths(repository).cas(cold.node(target).uid) + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes(manifests[target]) + cas.touch.touch() + + partial = analysis_discovery_slice(repository, toolchain) + partial_downstream = analysis_slice( + repository, + toolchain, + discovery=partial, + manifests=manifests, + ) + + self.assertEqual( + {node.name: node.uid for node in cold.nodes}, + {node.name: node.uid for node in partial.nodes}, + ) + self.assertEqual( + {node.name: node.uid for node in cold_downstream.nodes}, + {node.name: node.uid for node in partial_downstream.nodes}, + ) + + def test_header_content_invalidates_discovery_without_cached_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + before = analysis_discovery_slice(repository, toolchain) + (repository / "src/modules/renpy/pickle.h").write_text( + "#pragma once\n// dependency topology may have changed\n", + encoding="utf-8", + ) + after = analysis_discovery_slice(repository, toolchain) + + target = "discover-dependencies-x64-renpy-modules.renpy.pickle" + self.assertNotEqual(before.node(target).uid, after.node(target).uid) + + def test_cached_manifest_does_not_replace_stable_header_invalidation_or_scan_cas( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + initial = analysis_discovery_slice(repository, toolchain) + renpy = initial.node(initial.targets[0]) + cas = BuildPaths(repository).cas(renpy.uid) + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes( + self.manifest( + repository / "src/modules/renpy/pickle.cpp", + repository / "src/modules/renpy/pickle.h", + ) + ) + cas.touch.touch() + other = BuildPaths(repository).cas("1" * 32) + other.output.mkdir(parents=True) + (other.output / "dependencies.json").write_bytes( + (cas.output / "dependencies.json").read_bytes() + ) + other.touch.touch() + BuildPaths(repository).cas("2" * 32).entry.mkdir(parents=True) + + original_glob = Path.glob + + def reject_cas_scan(path: Path, pattern: str, **kwargs: object): + if path == BuildPaths(repository).cas_root: + raise AssertionError("CAS must not be scanned") + return original_glob(path, pattern, **kwargs) + + with mock.patch.object( + Path, "glob", autospec=True, side_effect=reject_cas_scan + ): + before = analysis_discovery_slice(repository, toolchain) + (repository / "src/modules/renpy/pickle.h").write_text( + "#pragma once\n// topology may have changed\n", encoding="utf-8" + ) + with mock.patch.object( + Path, "glob", autospec=True, side_effect=reject_cas_scan + ): + after = analysis_discovery_slice(repository, toolchain) + + self.assertNotEqual(before.node(before.targets[0]).uid, after.node(after.targets[0]).uid) + self.assertEqual(before.node(before.targets[1]).uid, after.node(after.targets[1]).uid) + + def test_source_namespace_addition_invalidates_discovery_for_has_include(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + before = analysis_discovery_slice(repository, toolchain) + (repository / "src/optional.h").write_text("#pragma once\n", encoding="utf-8") + after = analysis_discovery_slice(repository, toolchain) + + for name in before.targets: + self.assertNotEqual(before.node(name).uid, after.node(name).uid) + + def test_malformed_cas_candidate_is_ignored_during_discovery_seed_lookup(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + name = "discover-dependencies-x64-renpy-modules.renpy.pickle" + (repository / "out/cas" / f"{'z' * 32}-{name}").mkdir(parents=True) + (repository / "out/cas" / f"{'3' * 32}-discover-dependencies-x64-unused-unit").mkdir() + + graph = analysis_discovery_slice(repository, self.toolchain(root)) + + self.assertIn(name, graph.targets) + + def test_x64_only_leak_probe_is_not_scheduled_for_cross_architectures(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + (repository / "build/projects/leak-probe.vcxproj").write_bytes( + (repository / "build/projects/renpy.vcxproj").read_bytes() + ) + toolchain = self.toolchain(root) + _x86_discovery, x86 = self.staged_graphs( + repository, toolchain, architectures=("x86",) + ) + _x64_discovery, x64 = self.staged_graphs( + repository, toolchain, architectures=("x64",) + ) + + self.assertFalse(any("-leak-probe-" in node.name for node in x86.nodes)) + self.assertTrue(any("-leak-probe-" in node.name for node in x64.nodes)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_audit_graph.py b/build/tests/test_audit_graph.py new file mode 100644 index 0000000..f54207c --- /dev/null +++ b/build/tests/test_audit_graph.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from core.binary_audit import AuditError, main as binary_audit_main # noqa: E402 +from core.graph import Command, Graph, Node # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from graphs.audit import audit_graph # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "build", + Command((str(Path("C:/sdk/build.exe")),)), + inputs, + ) + + +class AuditGraphTests(unittest.TestCase): + def fixture(self, root: Path) -> tuple[Path, Graph, Path, Path]: + repository = root / "repo" + repository.mkdir() + restore = node("restore-release") + builds = tuple( + node(f"build-{module}-{architecture}-release", inputs=(restore.name,)) + for architecture in ("x64", "x86") + for module in ("renpy", "rpgmaker", "zanzarah") + ) + upstream = Graph( + (restore, *builds), + tuple(item.name for item in builds), + {"build": 2}, + ) + tools = root / "tools" + tools.mkdir() + dumpbin, binskim = tools / "dumpbin.exe", tools / "BinSkim.exe" + dumpbin.touch() + binskim.touch() + return repository, upstream, dumpbin, binskim + + def build_graph( + self, + root: Path, + *, + dumpbin_version: str = "14.44", + binskim_version: str = "4.4", + ) -> Graph: + repository, upstream, dumpbin, binskim = self.fixture(root) + return audit_graph( + repository, + upstream, + architectures=("x64", "x86"), + dumpbin=dumpbin, + dumpbin_identity={"path": str(dumpbin), "version": dumpbin_version}, + binskim=binskim, + binskim_identity={"path": str(binskim), "version": binskim_version}, + jobs=3, + ) + + def test_selects_canonical_release_producers_without_artifact_dtos(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream, dumpbin, binskim = self.fixture(Path(temporary)) + graph = audit_graph( + repository, + upstream, + architectures=("x64", "x86"), + dumpbin=dumpbin, + dumpbin_identity={"version": "14.44"}, + binskim=binskim, + binskim_identity={"version": "4.4"}, + ) + + self.assertEqual( + graph.node("audit-binskim-run-x64-renpy").inputs, + ("build-renpy-x64-release",), + ) + self.assertEqual( + graph.node("audit-binskim-run-x86-rpgmaker").inputs, + ("build-rpgmaker-x86-release",), + ) + + def test_each_artifact_composes_six_fine_grained_nodes_over_its_full_upstream(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + graph = self.build_graph(root) + + self.assertEqual(graph.pools, {"build": 2, "slot": 3}) + self.assertEqual( + graph.targets, + tuple( + f"audit-{kind}-{architecture}-{module}" + for architecture in ("x64", "x86") + for module in ("renpy", "rpgmaker", "zanzarah") + for kind in ("pe", "binskim") + ), + ) + self.assertEqual(43, len(graph.nodes)) + self.assertEqual(graph.node("build-renpy-x64-release").inputs, ("restore-release",)) + + for architecture in ("x64", "x86"): + for module in ("renpy", "rpgmaker", "zanzarah"): + producer = f"build-{module}-{architecture}-release" + dump_nodes = tuple( + graph.node(f"audit-dumpbin-{mode}-{architecture}-{module}") + for mode in ("headers", "dependents", "exports") + ) + pe_gate = graph.node(f"audit-pe-{architecture}-{module}") + binskim_run = graph.node(f"audit-binskim-run-{architecture}-{module}") + binskim_gate = graph.node(f"audit-binskim-{architecture}-{module}") + self.assertTrue(all(current.inputs == (producer,) for current in dump_nodes)) + self.assertEqual(pe_gate.inputs, tuple(current.name for current in dump_nodes)) + self.assertEqual(binskim_run.inputs, (producer,)) + self.assertEqual(binskim_gate.inputs, (binskim_run.name,)) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in binskim_run.results), + (( + f"reports/sarif/{architecture}/binskim-{module}.sarif", + "sarif", "application/sarif+json", "binskim.sarif", + ),), + ) + self.assertTrue(all(not current.results for current in (*dump_nodes, pe_gate, binskim_gate))) + self.assertTrue(all(current.pool == "slot" for current in dump_nodes)) + self.assertEqual((pe_gate.pool, binskim_run.pool, binskim_gate.pool), ("slot", "slot", "slot")) + + def test_dumpbin_and_python_gates_receive_exact_binary_logs_and_report_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, dumpbin, binskim = self.fixture(root) + graph = audit_graph( + repository, + upstream, + architectures=("x64",), + dumpbin=dumpbin, + dumpbin_identity={"version": "14.44"}, + binskim=binskim, + binskim_identity={"version": "4.4"}, + ) + paths = BuildPaths(repository) + binary = paths.cas(upstream.node("build-renpy-x64-release").uid).output / "renpy.so" + + dump_nodes = tuple( + graph.node(f"audit-dumpbin-{mode}-x64-renpy") + for mode in ("headers", "dependents", "exports") + ) + for mode, current in zip(("headers", "dependents", "exports"), dump_nodes, strict=True): + self.assertEqual(current.command.argv, (str(dumpbin), f"/{mode}", str(binary))) + pe_gate = graph.node("audit-pe-x64-renpy") + self.assertEqual(pe_gate.command.argv[1:5], ("-m", "core.binary_audit", "pe", "x64")) + self.assertEqual( + pe_gate.command.argv[5:], + tuple(str(paths.cas(current.uid).log) for current in dump_nodes), + ) + binskim_run = graph.node("audit-binskim-run-x64-renpy") + self.assertEqual( + binskim_run.command.argv[1:], + ("-m", "core.binary_audit", "run-binskim", str(binskim), str(binary)), + ) + binskim_gate = graph.node("audit-binskim-x64-renpy") + report = paths.cas(binskim_run.uid).output / "binskim.sarif" + self.assertEqual(binskim_gate.command.argv[1:], ("-m", "core.binary_audit", "binskim", str(report))) + + def test_tool_identity_invalidates_only_its_run_and_semantic_consumers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, dumpbin, binskim = self.fixture(root) + + def graph(dumpbin_version: str, binskim_version: str) -> Graph: + return audit_graph( + repository, + upstream, + architectures=("x64", "x86"), + dumpbin=dumpbin, + dumpbin_identity={"version": dumpbin_version}, + binskim=binskim, + binskim_identity={"version": binskim_version}, + ) + + before = graph("14.44", "4.4") + dump_changed = graph("14.45", "4.4") + binskim_changed = graph("14.44", "4.5") + + for current in before.nodes: + dump_partition = "dumpbin" in current.name or "audit-pe-" in current.name + binskim_partition = "binskim" in current.name + self.assertEqual( + dump_partition, + current.uid != dump_changed.node(current.name).uid, + current.name, + ) + self.assertEqual( + binskim_partition, + current.uid != binskim_changed.node(current.name).uid, + current.name, + ) + + def test_graph_rejects_invalid_axes_tools_pools_and_capacities(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, dumpbin, binskim = self.fixture(root) + + def invoke( + *, + source: Graph = upstream, + architectures: tuple[str, ...] = ("x64", "x86"), + dumpbin_path: Path = dumpbin, + jobs: object = 2, + ) -> Graph: + return audit_graph( + repository, + source, + architectures=architectures, + dumpbin=dumpbin_path, + dumpbin_identity={"version": "14.44"}, + binskim=binskim, + binskim_identity={"version": "4.4"}, + jobs=jobs, # type: ignore[arg-type] + ) + + for jobs in (True, "two", 0): + with self.subTest(jobs=jobs), self.assertRaisesRegex( + ValueError, "capacities" + ): + invoke(jobs=jobs) + + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(dumpbin_path=dumpbin.parent) + for invalid in ((), ("x64", "x64"), ("riscv64",)): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "architectures"): + invoke(architectures=invalid) + + incomplete = Graph( + tuple(item for item in upstream.nodes if item.name != "build-renpy-x64-release"), + tuple(name for name in upstream.targets if name != "build-renpy-x64-release"), + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "unknown node"): + invoke(source=incomplete) + + matching_pools = Graph( + upstream.nodes, + upstream.targets, + {"build": 2, "slot": 2}, + ) + self.assertEqual(invoke(source=matching_pools).pools["slot"], 2) + conflicting = Graph(upstream.nodes, upstream.targets, {"build": 2, "slot": 1}) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + invoke(source=conflicting) + + def test_binary_audit_cli_dispatches_pe_binskim_and_literal_binskim_run(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + headers, dependents, exports = root / "headers.txt", root / "dependents.txt", root / "exports.txt" + headers.write_text(" 8664 machine (x64)\n", encoding="utf-8") + dependents.write_text(" KERNEL32.dll\n", encoding="utf-8") + exports.write_text( + " 1 0 0001 LoadSubModule\n 2 1 0002 UnloadSubModule\n", encoding="utf-8" + ) + self.assertEqual( + 0, + binary_audit_main(("pe", "x64", str(headers), str(dependents), str(exports))), + ) + report = root / "approved.sarif" + report.write_text(json.dumps({"runs": []}), encoding="utf-8") + self.assertEqual(0, binary_audit_main(("binskim", str(report)))) + + output = root / "out" + output.mkdir() + tool, binary = root / "BinSkim.exe", root / "renpy.so" + tool.touch() + binary.touch() + + def complete(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + Path(argv[argv.index("--output") + 1]).write_text('{"runs":[]}', encoding="utf-8") + return subprocess.CompletedProcess(argv, 0) + + with mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(output)}, clear=False): + with mock.patch("core.binary_audit.subprocess.run", side_effect=complete) as invoked: + self.assertEqual(0, binary_audit_main(("run-binskim", str(tool), str(binary)))) + argv = invoked.call_args.args[0] + self.assertEqual(argv[:3], [str(tool), "analyze", str(binary)]) + self.assertIn("--disable-telemetry", argv) + self.assertEqual(output / "binskim.sarif", Path(argv[argv.index("--output") + 1])) + + with mock.patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(AuditError, "OBSERVER_OUT_DIR"): + binary_audit_main(("run-binskim", str(tool), str(binary))) + empty_output = root / "empty-output" + empty_output.mkdir() + with mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(empty_output)}, clear=False): + with mock.patch("core.binary_audit.subprocess.run"): + with self.assertRaisesRegex(AuditError, "did not produce"): + binary_audit_main(("run-binskim", str(tool), str(binary))) + + with ( + mock.patch.object(sys, "argv", ["binary_audit.py", "binskim", str(report)]), + self.assertWarnsRegex(RuntimeWarning, "core.binary_audit"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.binary_audit", run_name="__main__") + self.assertEqual(0, raised.exception.code) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_binary_audit.py b/build/tests/test_binary_audit.py new file mode 100644 index 0000000..bb48116 --- /dev/null +++ b/build/tests/test_binary_audit.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.binary_audit import AuditError, require_clean_binskim, require_release_pe # noqa: E402 + + +class BinaryAuditTests(unittest.TestCase): + def test_release_pe_requires_machine_static_dependencies_and_exact_exports(self) -> None: + require_release_pe( + "x64", + " 8664 machine (x64)\n", + " KERNEL32.dll\n api-ms-win-core-file-l1-1-0.dll\n", + " 1 0 0001 LoadSubModule\n 2 1 0002 UnloadSubModule\n", + ) + + failures = ( + ("x86", " 8664 machine (x64)\n", " KERNEL32.dll\n", " 1 0 1 LoadSubModule\n 2 1 2 UnloadSubModule\n", "machine"), + ("x64", " 8664 machine (x64)\n", " VCRUNTIME140.dll\n", " 1 0 1 LoadSubModule\n 2 1 2 UnloadSubModule\n", "dependencies"), + ("x64", " 8664 machine (x64)\n", " KERNEL32.dll\n", " 1 0 1 LoadSubModule\n 2 1 2 Surprise\n", "exports"), + ) + for architecture, headers, dependents, exports, message in failures: + with self.subTest(message=message), self.assertRaisesRegex(AuditError, message): + require_release_pe(architecture, headers, dependents, exports) + + def test_binskim_allows_only_documented_warning(self) -> None: + approved = { + "runs": [{ + "tool": {"driver": {"rules": [ + {"id": "BA2027", "defaultConfiguration": {"level": "warning"}} + ]}}, + "results": [{"ruleId": "BA2027"}], + }] + } + require_clean_binskim(approved) + + for result in ( + {"ruleId": "BA2001", "level": "warning"}, + {"ruleId": "BA2027", "level": "error"}, + ): + document = { + "runs": [{ + "tool": {"driver": {"rules": []}}, + "results": [result], + }] + } + with self.subTest(result=result), self.assertRaisesRegex(AuditError, result["ruleId"]): + require_clean_binskim(document) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_clang_dependencies.py b/build/tests/test_clang_dependencies.py new file mode 100644 index 0000000..3536f18 --- /dev/null +++ b/build/tests/test_clang_dependencies.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.clang_dependencies import ( # noqa: E402 + ClangDependencyError, + dependency_manifest, + load_compile_command, + main, + scan_dependencies, +) + + +class ClangDependencyTests(unittest.TestCase): + def fixture(self, root: Path) -> tuple[Path, Path, Path, Path]: + source = root / "source file.cpp" + compiler = root / "clang-cl.exe" + scanner = root / "clang-scan-deps.exe" + command = root / "compile-command.json" + for path in (source, compiler, scanner): + path.touch() + command.write_text( + json.dumps( + { + "directory": str(root), + "file": str(source), + "output": "source.obj", + "arguments": [ + str(compiler), + "-xc++", + str(source), + "-o", + "source.obj", + "/clang:-MJcapture.json", + ], + } + ) + + ",\n", + encoding="utf-8", + ) + return source, compiler, scanner, command + + @staticmethod + def scan_document(source: Path, *includes: Path) -> dict[str, object]: + return { + "modules": [], + "translation-units": [ + { + "commands": [ + { + "input-file": str(source), + "file-deps": [str(source), *(str(path) for path in includes)], + } + ] + } + ], + } + + def test_load_command_validates_exact_compiler_source_and_removes_only_capture_flag(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, compiler, _scanner, command = self.fixture(root) + other_source = root / "other.cpp" + other_compiler = root / "other-clang.exe" + other_source.touch() + other_compiler.touch() + + loaded = load_compile_command(command, source, compiler) + + self.assertEqual(loaded["directory"], str(root.resolve())) + self.assertEqual(loaded["file"], str(source.resolve())) + self.assertEqual(loaded["arguments"][0], str(compiler.resolve())) + self.assertNotIn("/clang:-MJcapture.json", loaded["arguments"]) + self.assertIn("source.obj", loaded["arguments"]) + + for field, value, message in ( + ("directory", str(root / "missing"), "directory"), + ("directory", str(source), "directory"), + ("file", str(root / "missing.cpp"), "source"), + ("file", str(other_source), "source"), + ("arguments", [str(root / "missing-clang.exe")], "compiler"), + ("arguments", [str(other_compiler)], "compiler"), + ("arguments", "not-an-array", "arguments"), + ("arguments", [], "arguments"), + ("arguments", [str(compiler), 7], "arguments"), + ): + document = json.loads(command.read_text(encoding="utf-8").rstrip("\n,")) + document[field] = value + command.write_text(json.dumps(document) + ",\n", encoding="utf-8") + with self.subTest(field=field), self.assertRaisesRegex( + ClangDependencyError, message + ): + load_compile_command(command, source, compiler) + self.fixture(root) + + command.write_text("not json,\n", encoding="utf-8") + with self.assertRaisesRegex(ClangDependencyError, "JSON"): + load_compile_command(command, source, compiler) + command.write_text("[]", encoding="utf-8") + with self.assertRaisesRegex(ClangDependencyError, "JSON object"): + load_compile_command(command, source, compiler) + self.fixture(root) + command.write_text( + command.read_text(encoding="utf-8").rstrip("\n,"), encoding="utf-8" + ) + self.assertEqual(load_compile_command(command, source, compiler)["file"], str(source)) + + def test_scan_output_becomes_deterministic_absolute_deduplicated_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, first, second = root / "source.cpp", root / "first.h", root / "second.h" + for path in (source, first, second): + path.touch() + + manifest = dependency_manifest( + source, + self.scan_document(source, second, first, second), + ) + + self.assertEqual( + manifest, + { + "Data": { + "Source": str(source.resolve()), + "Includes": [str(first.resolve()), str(second.resolve())], + } + }, + ) + + def test_scan_output_rejects_missing_source_and_invalid_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source.cpp" + source.touch() + missing = root / "missing.h" + other = root / "other.cpp" + other.touch() + directory = root / "directory" + directory.mkdir() + invalid_documents = ( + "bad", + {}, + {"translation-units": "bad"}, + {"translation-units": []}, + {"translation-units": [{}]}, + {"translation-units": [{"commands": []}]}, + {"translation-units": [{"commands": [{}]}]}, + {"translation-units": [{"commands": [{"file-deps": "bad"}]}]}, + {"translation-units": [{"commands": [{"file-deps": [str(source), 7]}]}]}, + {"translation-units": [{"commands": [{"file-deps": ["relative.h"]}]}]}, + self.scan_document(source, directory), + self.scan_document(source, missing), + self.scan_document(other), + ) + for document in invalid_documents: + with self.subTest(document=document), self.assertRaisesRegex( + ClangDependencyError, "scan output" + ): + dependency_manifest(source, document) + with self.assertRaisesRegex(ClangDependencyError, "source"): + dependency_manifest(root / "missing.cpp", self.scan_document(source)) + with self.assertRaisesRegex(ClangDependencyError, "source"): + dependency_manifest(root, self.scan_document(source)) + + def test_scan_runs_exact_tool_with_captured_compilation_database(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, compiler, scanner, command = self.fixture(root) + header = root / "header.h" + header.touch() + build = root / "build" + build.mkdir() + captured: dict[str, object] = {} + + def run(argv: list[str], **options: object) -> subprocess.CompletedProcess[str]: + database = Path(next(item.split("=", 1)[1] for item in argv if item.startswith("-compilation-database="))) + captured["argv"] = argv + captured["options"] = options + captured["database"] = json.loads(database.read_text(encoding="utf-8")) + return subprocess.CompletedProcess( + argv, 0, json.dumps(self.scan_document(source, header)), "" + ) + + with mock.patch("core.clang_dependencies.subprocess.run", side_effect=run): + manifest = scan_dependencies(source, command, scanner, compiler, build) + + self.assertEqual(manifest["Data"]["Includes"], [str(header.resolve())]) + self.assertEqual(captured["argv"][0], str(scanner.resolve())) + self.assertIn("-format=experimental-full", captured["argv"]) + self.assertEqual(captured["options"]["cwd"], str(root.resolve())) + self.assertEqual(len(captured["database"]), 1) + self.assertTrue(Path(captured["argv"][2].split("=", 1)[1]).is_relative_to(build)) + + def test_scan_failure_and_invalid_json_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, compiler, scanner, command = self.fixture(root) + build = root / "build" + build.mkdir() + cases = ( + (subprocess.CompletedProcess([], 2, "", "bad flags"), "failed"), + (subprocess.CompletedProcess([], 2, "", ""), "no diagnostic"), + (subprocess.CompletedProcess([], 0, "not json", ""), "JSON"), + ) + for result, message in cases: + with ( + self.subTest(message=message), + mock.patch("core.clang_dependencies.subprocess.run", return_value=result), + self.assertRaisesRegex(ClangDependencyError, message), + ): + scan_dependencies(source, command, scanner, compiler, build) + with ( + mock.patch("core.clang_dependencies.subprocess.run", side_effect=OSError("boom")), + self.assertRaisesRegex(ClangDependencyError, "launch"), + ): + scan_dependencies(source, command, scanner, compiler, build) + for invalid_build in (root / "missing", source): + with self.assertRaisesRegex(ClangDependencyError, "build directory"): + scan_dependencies( + source, command, scanner, compiler, invalid_build + ) + + def test_cli_writes_canonical_json_only_below_observer_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, compiler, scanner, command = self.fixture(root) + output = root / "out" + output.mkdir() + build = root / "build" + build.mkdir() + expected = {"Data": {"Source": str(source.resolve()), "Includes": []}} + arguments = ("scan", str(source), str(command), str(scanner), str(compiler)) + with ( + mock.patch("core.clang_dependencies.scan_dependencies", return_value=expected), + mock.patch.dict( + os.environ, + {"OBSERVER_OUT_DIR": str(output), "OBSERVER_BUILD_DIR": str(build)}, + ), + ): + self.assertEqual(main(arguments), 0) + self.assertEqual( + (output / "dependencies.json").read_text(encoding="utf-8"), + json.dumps(expected, sort_keys=True, separators=(",", ":")) + "\n", + ) + + for environment, message in ( + ({}, "OBSERVER_OUT_DIR"), + ({"OBSERVER_OUT_DIR": str(source)}, "OBSERVER_OUT_DIR"), + ({"OBSERVER_OUT_DIR": str(output)}, "OBSERVER_BUILD_DIR"), + ( + { + "OBSERVER_OUT_DIR": str(output), + "OBSERVER_BUILD_DIR": str(source), + }, + "OBSERVER_BUILD_DIR", + ), + ( + { + "OBSERVER_OUT_DIR": str(output), + "OBSERVER_BUILD_DIR": str(root / "missing"), + }, + "OBSERVER_BUILD_DIR", + ), + ): + with ( + self.subTest(environment=environment), + mock.patch.dict(os.environ, environment, clear=True), + self.assertRaisesRegex(ClangDependencyError, message), + ): + main(arguments) + with ( + mock.patch.object(sys, "argv", ["clang_dependencies.py", *arguments]), + mock.patch.dict( + os.environ, + {"OBSERVER_OUT_DIR": str(output), "OBSERVER_BUILD_DIR": str(build)}, + ), + mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + [], 0, json.dumps(self.scan_document(source)), "" + ), + ), + self.assertWarnsRegex(RuntimeWarning, "core.clang_dependencies"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.clang_dependencies", run_name="__main__") + self.assertEqual(raised.exception.code, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_clean.py b/build/tests/test_clean.py new file mode 100644 index 0000000..f7fd8b4 --- /dev/null +++ b/build/tests/test_clean.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from io import StringIO +import os +from pathlib import Path +import runpy +import shutil +import stat +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +from filelock import FileLock, Timeout + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +import core.clean as core_clean # noqa: E402 +from core.clean import CleanError, clean, main # noqa: E402 +from core.paths import BuildPaths, PathSafetyError # noqa: E402 + + +UID = "0123456789abcdef0123456789abcdef" +LIVE_UID = "11111111111111111111111111111111" +ACTIVE_UID = "22222222222222222222222222222222" + + +class CleanTests(unittest.TestCase): + def repository(self, root: Path) -> tuple[Path, BuildPaths]: + repository = root / "repo" + repository.mkdir(parents=True) + return repository, BuildPaths(repository) + + @staticmethod + def complete_cas(paths: BuildPaths, uid: str) -> Path: + entry = paths.cas(uid) + entry.output.mkdir(parents=True) + entry.log.touch() + entry.touch.touch() + return entry.entry + + def test_cas_sweep_uses_explicit_liveness_and_removes_incomplete_entries(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + live = self.complete_cas(paths, LIVE_UID) + inactive = self.complete_cas(paths, UID) + os.utime(paths.cas(LIVE_UID).touch, ns=(1, 1)) + os.utime(paths.cas(UID).touch, ns=(2_000_000_000, 2_000_000_000)) + incomplete = paths.cas(ACTIVE_UID) + incomplete.output.mkdir(parents=True) + incomplete.log.touch() + legacy = paths.cas_root / f"{UID}-legacy" + legacy.mkdir() + + removed = core_clean.sweep_cas(repository, {LIVE_UID}) + + self.assertEqual(removed, (inactive, incomplete.entry)) + self.assertTrue(live.is_dir()) + self.assertFalse(inactive.exists()) + self.assertFalse(incomplete.entry.exists()) + self.assertTrue(legacy.is_dir()) + + def test_cas_sweep_skips_active_nodes_and_holds_both_locks_while_removing(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + inactive = self.complete_cas(paths, UID) + active = self.complete_cas(paths, ACTIVE_UID) + original_remove = shutil.rmtree + + def remove(target: Path) -> None: + for lock_path in (paths.coordination_lock(), paths.lock(UID)): + with self.assertRaises(Timeout), FileLock( + lock_path, timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + original_remove(target) + + active_lock = FileLock( + paths.lock(ACTIVE_UID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + with active_lock, mock.patch( + "core.clean.shutil.rmtree", side_effect=remove + ): + self.assertEqual(core_clean.sweep_cas(repository, ()), (inactive,)) + + self.assertTrue(active.is_dir()) + + def test_cas_sweep_refuses_to_delete_while_another_run_lease_is_active(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + inactive = self.complete_cas(paths, UID) + lease = FileLock( + paths.lease("active-run"), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + + with lease, self.assertRaisesRegex(CleanError, "active build lease"): + core_clean.sweep_cas(repository, ()) + + self.assertTrue(inactive.is_dir()) + self.assertEqual(core_clean.sweep_cas(repository, ()), (inactive,)) + + def test_cas_sweep_ignores_only_the_explicit_owned_active_lease(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + inactive = self.complete_cas(paths, UID) + owned = FileLock( + paths.lease("owned-run"), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + + with owned: + self.assertEqual( + core_clean.sweep_cas( + repository, (), owned_run_id="owned-run" + ), + (inactive,), + ) + + inactive = self.complete_cas(paths, UID) + other = FileLock( + paths.lease("other-run"), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ) + with owned, other, self.assertRaisesRegex(CleanError, "other-run"): + core_clean.sweep_cas(repository, (), owned_run_id="owned-run") + self.assertTrue(inactive.is_dir()) + + with self.assertRaisesRegex(CleanError, "owned run lease is not active"): + core_clean.sweep_cas(repository, (), owned_run_id="owned-run") + with self.assertRaisesRegex(CleanError, "owned run lease is missing"): + core_clean.sweep_cas(repository, (), owned_run_id="missing-run") + self.assertTrue(inactive.is_dir()) + + def test_cas_sweep_rechecks_paths_under_lock_and_rejects_unsafe_inputs(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, paths = self.repository(root / "races") + paths.prepare() + inactive = self.complete_cas(paths, UID) + original_cas = BuildPaths.cas + calls = 0 + + def recheck(instance: BuildPaths, uid: str): + nonlocal calls + candidate = original_cas(instance, uid) + calls += 1 + if calls == 2: + with self.assertRaises(Timeout), FileLock( + paths.lock(UID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + raise PathSafetyError("reparse point appeared") + return candidate + + with mock.patch.object(BuildPaths, "cas", autospec=True, side_effect=recheck), \ + self.assertRaisesRegex(PathSafetyError, "reparse point"): + core_clean.sweep_cas(repository, ()) + self.assertEqual(calls, 2) + self.assertTrue(inactive.is_dir()) + + with self.assertRaisesRegex(PathSafetyError, "UID"): + core_clean.sweep_cas(repository, {UID.upper()}) + self.assertTrue(inactive.is_dir()) + + malformed_lease = paths.locks_root / "run-.lease" + malformed_lease.touch() + with self.assertRaisesRegex(CleanError, "unexpected run lease"): + core_clean.sweep_cas(repository, ()) + self.assertTrue(inactive.is_dir()) + malformed_lease.unlink() + + with FileLock( + paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ), self.assertRaisesRegex(CleanError, "coordination lock"): + core_clean.sweep_cas(repository, ()) + self.assertTrue(inactive.is_dir()) + + reparse_repository, reparse = self.repository(root / "reparse") + reparse.prepare() + reparse_entry = self.complete_cas(reparse, UID) + with mock.patch( + "core.paths._is_reparse", + side_effect=lambda path: Path(path) == reparse_entry, + ), self.assertRaisesRegex(PathSafetyError, "reparse point"): + core_clean.sweep_cas(reparse_repository, ()) + self.assertTrue(reparse_entry.is_dir()) + + empty_repository, _empty = self.repository(root / "empty") + self.assertEqual(core_clean.sweep_cas(empty_repository, ()), ()) + + vanished_repository, vanished = self.repository(root / "vanished") + vanished.prepare() + with mock.patch("core.clean._validate", side_effect=(True, False)): + self.assertEqual(core_clean.sweep_cas(vanished_repository, ()), ()) + + no_cas_repository, no_cas = self.repository(root / "no-cas") + no_cas.output_root.mkdir() + no_cas.work_root.mkdir() + self.assertEqual(core_clean.sweep_cas(no_cas_repository, ()), ()) + + def test_all_removes_only_exact_generated_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + current = paths.cas(UID).entry + current.mkdir() + (current / "data").write_text("generated", encoding="utf-8") + legacy = paths.cas_root / f"{UID}-legacy-node" + legacy.mkdir() + (legacy / "data").write_text("old generated", encoding="utf-8") + run = paths.run_work("old-run") + run.mkdir() + inactive_lock = paths.lock(UID) + with FileLock(inactive_lock, timeout=0, fallback_to_soft=False, + preserve_lock_file=True): + pass + keep = repository / "keep.txt" + keep.write_text("user", encoding="utf-8") + + self.assertEqual(clean(repository), (paths.cas_root, run, inactive_lock)) + + self.assertFalse(paths.cas_root.exists()) + self.assertFalse(run.exists()) + self.assertEqual( + tuple(paths.locks_root.iterdir()), (paths.coordination_lock(),) + ) + self.assertEqual(keep.read_text(encoding="utf-8"), "user") + + paths.prepare() + self.assertTrue(paths.cas_root.is_dir()) + self.assertTrue(paths.work_root.is_dir()) + + def test_stale_work_removes_only_inactive_exact_run_directories(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + cached = paths.cas(UID).output + cached.mkdir(parents=True) + (cached / "module.so").touch() + legacy = paths.cas_root / f"{UID}-legacy-node" / "out" + legacy.mkdir(parents=True) + (legacy / "module.so").touch() + runs = tuple(paths.run_work(name) for name in ("run-b", "run-a")) + for run in runs: + run.mkdir() + (run / "scratch.obj").touch() + lock = FileLock(paths.lock(UID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True) + with lock: + pass + + removed = clean(repository, "stale-work") + + self.assertEqual(removed, tuple(sorted(runs))) + self.assertTrue((cached / "module.so").is_file()) + self.assertTrue((legacy / "module.so").is_file()) + self.assertTrue(paths.locks_root.is_dir()) + self.assertTrue(paths.lock(UID).is_file()) + self.assertTrue(all(not run.exists() for run in runs)) + + def test_active_native_lock_refuses_every_mode_without_deleting(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + run = paths.run_work("active-run") + run.mkdir() + marker = run / "partial.obj" + marker.touch() + lock = FileLock(paths.lock(UID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True) + with lock: + for mode in ("all", "stale-work"): + with self.subTest(mode=mode), self.assertRaisesRegex(CleanError, "active build"): + clean(repository, mode) + self.assertTrue(marker.is_file()) + + def test_active_run_lease_refuses_and_coordination_is_held_through_removal(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + run = paths.run_work("leased-run") + run.mkdir() + lease = FileLock(paths.lease("leased-run"), timeout=0, fallback_to_soft=False, + preserve_lock_file=True) + with lease, self.assertRaisesRegex(CleanError, "active build"): + clean(repository, "stale-work") + self.assertTrue(run.is_dir()) + + original_remove = shutil.rmtree + + def remove(target: Path) -> None: + with self.assertRaises(Timeout), FileLock( + paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + original_remove(target) + + with mock.patch("core.clean.shutil.rmtree", side_effect=remove): + self.assertEqual(clean(repository, "stale-work"), (run,)) + + def test_unsafe_layout_types_and_reparse_points_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + file_repository, file_paths = self.repository(root / "file") + file_paths.output_root.touch() + with self.assertRaisesRegex(CleanError, "directory"): + clean(file_repository) + + unexpected_repository, unexpected = self.repository(root / "unexpected") + unexpected.output_root.mkdir() + (unexpected.output_root / "personal.txt").touch() + with self.assertRaisesRegex(CleanError, "unexpected output entry"): + clean(unexpected_repository) + + child_repository, child = self.repository(root / "child-file") + child.output_root.mkdir() + child.cas_root.touch() + with self.assertRaisesRegex(CleanError, "not a directory"): + clean(child_repository) + + reparse_repository, reparse = self.repository(root / "reparse") + reparse.prepare() + reparse_entry = reparse.cas(UID).entry + reparse_entry.mkdir() + with mock.patch( + "core.paths._is_reparse", side_effect=lambda path: Path(path) == reparse_entry + ), self.assertRaisesRegex(PathSafetyError, "reparse point"): + clean(reparse_repository) + + resolved_repository, resolved = self.repository(root / "resolved") + resolved.output_root.mkdir() + original_resolve = Path.resolve + + def resolve(path: Path, *, strict: bool = False) -> Path: + if path == resolved.output_root: + return resolved.repository / "elsewhere" + return original_resolve(path, strict=strict) + + with mock.patch.object(Path, "resolve", resolve), self.assertRaisesRegex( + CleanError, "does not resolve" + ): + clean(resolved_repository) + + special_repository, special = self.repository(root / "special") + special.prepare() + special_entry = special.cas(UID).entry + special_entry.touch() + original_lstat = os.lstat + + def lstat(path: Path) -> os.stat_result | SimpleNamespace: + return (SimpleNamespace(st_mode=stat.S_IFIFO) + if Path(path) == special_entry else original_lstat(path)) + + with mock.patch("core.clean.os.lstat", side_effect=lstat), self.assertRaisesRegex( + CleanError, "unsupported output entry type" + ): + clean(special_repository) + + def test_invalid_lock_work_entry_mode_and_missing_output_are_safe(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + empty_repository, empty = self.repository(root / "empty") + self.assertEqual(clean(empty_repository), ()) + self.assertFalse(empty.output_root.exists()) + with self.assertRaisesRegex(ValueError, "mode"): + clean(empty_repository, "unknown") + + no_work_repository, no_work = self.repository(root / "no-work") + no_work.output_root.mkdir() + no_work.cas_root.mkdir() + self.assertEqual(clean(no_work_repository, "stale-work"), ()) + + lock_repository, locks = self.repository(root / "bad-lock") + locks.prepare() + (locks.locks_root / "surprise.txt").touch() + with self.assertRaisesRegex(CleanError, "lock entry"): + clean(lock_repository) + + (locks.locks_root / "surprise.txt").unlink() + (locks.locks_root / "bad.lock").touch() + with self.assertRaisesRegex(CleanError, "lock entry"): + clean(lock_repository) + + (locks.locks_root / "bad.lock").unlink() + (locks.locks_root / "directory.lock").mkdir() + with self.assertRaisesRegex(CleanError, "lock entry"): + clean(lock_repository) + + work_repository, work = self.repository(root / "bad-work") + work.prepare() + (work.work_root / "unexpected.txt").touch() + with self.assertRaisesRegex(CleanError, "work entry"): + clean(work_repository, "stale-work") + (work.work_root / "unexpected.txt").unlink() + (work.work_root / "bad name").mkdir() + with self.assertRaisesRegex(CleanError, "work entry"): + clean(work_repository, "stale-work") + + def test_coordination_races_are_rejected_without_claiming_removal(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, paths = self.repository(root / "active") + paths.prepare() + with FileLock(paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True), self.assertRaisesRegex( + CleanError, "coordination lock" + ): + clean(repository) + + vanished_repository, vanished = self.repository(root / "vanished") + vanished.prepare() + with mock.patch("core.clean._validate", side_effect=(True, False)): + self.assertEqual(clean(vanished_repository), ()) + + no_cas_repository, no_cas = self.repository(root / "no-cas") + no_cas.work_root.mkdir(parents=True) + self.assertEqual(clean(no_cas_repository), ()) + self.assertEqual( + tuple(no_cas.locks_root.iterdir()), (no_cas.coordination_lock(),) + ) + + def test_cli_prints_exact_removed_paths_and_module_entry_point_is_safe(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, paths = self.repository(Path(temporary)) + paths.prepare() + output = StringIO() + with redirect_stdout(output): + self.assertEqual(main((str(repository), "--mode", "stale-work")), 0) + self.assertEqual(output.getvalue(), "") + + paths.run_work("old-run").mkdir() + with redirect_stdout(output): + self.assertEqual(main((str(repository), "--mode", "stale-work")), 0) + self.assertIn(str(paths.run_work("old-run")), output.getvalue()) + + safe = Path(temporary) / "entry" + safe.mkdir() + with mock.patch.object(sys, "argv", ["clean.py", str(safe)]), \ + redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised: + runpy.run_path(str(BUILD_ROOT / "core/clean.py"), run_name="__main__") + self.assertEqual(raised.exception.code, 0) + self.assertFalse((safe / "out").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_common.py b/build/tests/test_common.py new file mode 100644 index 0000000..69cb977 --- /dev/null +++ b/build/tests/test_common.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path +import tempfile +import unittest + +from core.graph import Graph, Result +from core.node import NodeFactory +from core.render import TemplateRenderer +from graphs.common import BUILD_ROOT, recipe_factory, restore_node + + +@dataclass(frozen=True) +class Toolchain: + pwsh: Path + vcpkg_root: Path + environment: tuple[tuple[str, str], ...] + identity: tuple[tuple[str, str], ...] + + +def repository(root: Path) -> Path: + (root / "build/vcpkg/triplets").mkdir(parents=True) + (root / "vcpkg.json").write_text("{}\n", encoding="utf-8") + for flavor in ("", "-asan"): + (root / f"build/vcpkg/triplets/observer-x64-windows-static{flavor}.cmake").write_text( + f"triplet{flavor}\n", encoding="utf-8" + ) + return root + + +class RecipeFactoryTests(unittest.TestCase): + def test_matches_direct_node_factory_contract(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + cwd = Path(temporary) / "working-directory" + identity = {"compiler": "exact-version", "tool": "exact-path"} + environment = (("ZED", "last"), ("ALPHA", "first")) + result = Result("reports/sample.json", "report", "application/json", "sample.json") + + def make(factory: NodeFactory): + return factory.make( + "argv.json", "sample", "slot", {"argv": ("tool.exe", "--probe")}, + files={}, results=(result,), config={"action": "probe"}, + ) + + expected = make(NodeFactory( + TemplateRenderer(BUILD_ROOT / "templates"), cwd, identity, environment + )) + actual = make(recipe_factory(cwd, identity, environment)) + + self.assertEqual(actual, expected) + + +class RestoreNodeTests(unittest.TestCase): + def fixture(self, root: Path) -> tuple[Path, Toolchain]: + repo = repository(root / "repo") + tools = root / "tools" + vcpkg = tools / "vcpkg" + vcpkg.mkdir(parents=True) + pwsh = tools / "pwsh.exe" + pwsh.write_bytes(b"pwsh-one") + (vcpkg / "vcpkg.exe").write_bytes(b"vcpkg-one") + return repo, Toolchain( + pwsh, vcpkg, + (("ZED", "last"), ("Path", str(tools))), + (("pwsh_version", "7.5"), ("unrelated", "first")), + ) + + def test_restore_identity_ignores_parent_factory_and_wrapper_noise(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repo, first = self.fixture(Path(temporary)) + second = replace( + first, + environment=(("PATH", str(first.pwsh.parent)), ("zed", "last")), + identity=(("unrelated", "second"), ("pwsh_version", "wrapper-noise")), + ) + renderer = TemplateRenderer(BUILD_ROOT / "templates") + factories = ( + NodeFactory(renderer, repo / "wrong-one", {"compiler": "one"}, (("NOISE", "one"),)), + NodeFactory(renderer, repo / "wrong-two", {"compiler": "two"}, (("NOISE", "two"),)), + ) + pairs = tuple( + ( + restore_node(repo, first, factories[0], "x64", flavor=flavor), + restore_node(repo, second, factories[1], "x64", flavor=flavor), + ) + for flavor in ("", "asan") + ) + + for left, right in pairs: + self.assertEqual(left, right) + nodes = tuple({node.name: node for pair in pairs for node in pair}.values()) + self.assertEqual(len(nodes), 2) + Graph(nodes, tuple(node.name for node in nodes), {"restore": 1}) + + def test_exact_tool_and_triplet_changes_invalidate_restore(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repo, toolchain = self.fixture(Path(temporary)) + factory = NodeFactory( + TemplateRenderer(BUILD_ROOT / "templates"), repo, {"unrelated": "identity"} + ) + + baseline = restore_node(repo, toolchain, factory, "x64") + toolchain.pwsh.write_bytes(b"pwsh-two") + pwsh_changed = restore_node(repo, toolchain, factory, "x64") + (toolchain.vcpkg_root / "vcpkg.exe").write_bytes(b"vcpkg-two") + vcpkg_changed = restore_node(repo, toolchain, factory, "x64") + (repo / "build/vcpkg/triplets/observer-x64-windows-static.cmake").write_text( + "changed triplet\n", encoding="utf-8" + ) + triplet_changed = restore_node(repo, toolchain, factory, "x64") + + self.assertNotEqual(baseline.uid, pwsh_changed.uid) + self.assertNotEqual(pwsh_changed.uid, vcpkg_changed.uid) + self.assertNotEqual(vcpkg_changed.uid, triplet_changed.uid) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_core_coverage.py b/build/tests/test_core_coverage.py new file mode 100644 index 0000000..3e6520b --- /dev/null +++ b/build/tests/test_core_coverage.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import asyncio +import hashlib +import io +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.binary_audit import AuditError, require_release_pe # noqa: E402 +from core.execute import ExecutionError, Executor # noqa: E402 +from core.graph import Command, Graph, GraphError, Node # noqa: E402 +from core.node import NodeFactory # noqa: E402 +from core.paths import BuildPaths, PathSafetyError # noqa: E402 +from core.render import TemplateRenderer # noqa: E402 +from core.sarif import ( # noqa: E402 + SarifError, + normalize_msvc, + require_clean, +) +from core.store import CasStateError, CasStore # noqa: E402 +from core.toolchain import _existing, _output # noqa: E402 +from core.windows_process import WindowsProcessRunner, _reap # noqa: E402 + + +UID = "0123456789abcdef0123456789abcdef" +RUN_ID = "20260802T000000Z-coverage" + + +def node( + name: str = "leaf", *, inputs: tuple[str, ...] = (), command: Command | None = None +) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "cpu", + command or Command(("tool",)), + inputs, + ) + + +def write_sarif(path: Path, runs: list[object]) -> None: + path.write_text( + json.dumps({"version": "2.1.0", "runs": runs}), encoding="utf-8" + ) + + +class CoreValidationTests(unittest.TestCase): + def test_release_audit_reports_an_unsupported_architecture(self) -> None: + with self.assertRaisesRegex(AuditError, "unsupported.*riscv64") as raised: + require_release_pe("riscv64", "", "", "") + + self.assertIsInstance(raised.exception.__cause__, KeyError) + + def test_command_and_node_reject_ambiguous_runtime_types(self) -> None: + invalid_commands = ( + lambda: Command(()), + lambda: Command((1,)), + lambda: Command(("tool",), env=(("ONLY-KEY",),)), + lambda: Command(("tool",), env=(("KEY", 1),)), + lambda: Command(("tool",), env=(("KEY", "bad\0value"),)), + lambda: Command(("tool",), env=((1, "value"),)), + ) + for constructor in invalid_commands: + with self.subTest(constructor=constructor), self.assertRaises(GraphError): + constructor() # type: ignore[call-arg] + + invalid_nodes = ( + lambda: Node(1, UID, "cpu", Command(("tool",))), + lambda: Node("leaf", 1, "cpu", Command(("tool",))), + lambda: Node("leaf", UID, 1, Command(("tool",))), + lambda: Node("leaf", UID, "cpu", object()), + lambda: Node("leaf", UID, "cpu", Command(("tool",)), (1,)), + ) + for constructor in invalid_nodes: + with self.subTest(constructor=constructor), self.assertRaises(GraphError): + constructor() # type: ignore[call-arg] + + def test_graph_rejects_non_integral_pool_capacity_and_sparse_cycle_error( + self, + ) -> None: + with self.assertRaisesRegex(GraphError, "invalid pool capacity"): + Graph((node(),), ("leaf",), {"cpu": "many"}) # type: ignore[dict-item] + + sorter = mock.Mock() + sorter.prepare.side_effect = __import__("graphlib").CycleError("cycle") + with ( + mock.patch("core.graph.TopologicalSorter", return_value=sorter), + self.assertRaisesRegex(GraphError, r"dependency cycle:\s*$"), + ): + Graph((node(),), ("leaf",), {"cpu": 1}) + + def test_node_factory_renders_signs_and_applies_runtime_overrides(self) -> None: + factory = NodeFactory( + TemplateRenderer(BUILD_ROOT / "templates"), + Path(r"C:\repo\default-work"), + {"compiler": "msvc-default"}, + (("DEFAULT", "environment"),), + ) + dependency = node("restore") + variables = { + "pwsh": "pwsh.exe", + "msbuild": r"C:\VS\MSBuild.exe", + "project": r"C:\repo\renpy.vcxproj", + "target": "Build", + "configuration": "Release", + "platform": "x64", + "msbuild_args": [], + } + current = factory.make( + "msbuild.ps1", + "build-renpy-x64", + "cpu", + variables, + files={"renpy.vcxproj": b"project bytes"}, + dependencies=(dependency,), + config={"architecture": "x64"}, + ) + overridden = factory.make( + "msbuild.ps1", + "build-renpy-x64", + "cpu", + variables, + files={"renpy.vcxproj": b"project bytes"}, + dependencies=(dependency,), + config={"architecture": "x64"}, + identity={"compiler": "msvc-override"}, + environment=(("OVERRIDE", "environment"),), + cwd=Path(r"C:\repo\override-work"), + ) + + self.assertEqual(current.inputs, ("restore",)) + self.assertEqual(current.command.env, (("DEFAULT", "environment"),)) + self.assertEqual(current.command.cwd, r"C:\repo\default-work") + self.assertIn(b"C:\\VS\\MSBuild.exe", current.command.stdin) + self.assertEqual(overridden.command.env, (("OVERRIDE", "environment"),)) + self.assertEqual(overridden.command.cwd, r"C:\repo\override-work") + self.assertNotEqual(current.uid, overridden.uid) + + def test_path_policy_rejects_a_candidate_outside_the_repository(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repository" + repository.mkdir() + paths = BuildPaths(repository) + + with self.assertRaisesRegex(PathSafetyError, "outside repository"): + paths._reject_existing_reparse_points(repository.parent) + + def test_cas_cannot_publish_when_entry_exists_without_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + paths = BuildPaths(repository) + paths.prepare() + store = CasStore(paths, RUN_ID) + current = node() + entry = store.paths_for(current) + entry.entry.mkdir() + entry.log.touch() + + with self.assertRaisesRegex(CasStateError, "without output directory"): + store.mark_complete(current) + + self.assertFalse(entry.touch.exists()) + + def test_tool_discovery_explains_missing_paths_and_empty_tool_output(self) -> None: + with self.assertRaisesRegex(FileNotFoundError, "required path not found: None"): + _existing(None) + + completed = subprocess.CompletedProcess(["tool"], 0, stdout=" \r\n", stderr="") + with ( + mock.patch("core.toolchain.subprocess.run", return_value=completed) as run, + self.assertRaisesRegex(RuntimeError, "tool returned empty output: tool"), + ): + _output(["tool"]) + + run.assert_called_once_with( + ["tool"], check=True, capture_output=True, text=True + ) + + +class ExecutorLifecycleTests(unittest.IsolatedAsyncioTestCase): + async def test_executor_rejects_reuse_after_a_successful_build(self) -> None: + current = node() + complete: set[str] = set() + + async def run(built: Node) -> None: + self.assertEqual(built, current) + + executor = Executor( + Graph((current,), (current.name,), {"cpu": 1}), + is_complete=lambda candidate: candidate.uid in complete, + runner=run, + publish=lambda candidate: complete.add(candidate.uid), + ) + await executor.run() + + with self.assertRaisesRegex(ExecutionError, "single-use"): + await executor.run() + + +class SarifFailureTests(unittest.TestCase): + def test_sarif_read_errors_retain_the_report_path_and_cause(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + malformed = root / "malformed.sarif" + malformed.write_text("{", encoding="utf-8") + missing = root / "missing.sarif" + + for path in (malformed, missing): + with self.subTest(path=path), self.assertRaises(SarifError) as raised: + normalize_msvc(path, root / "output.sarif", "analysis/") + self.assertIn(f"cannot read SARIF report {path}", str(raised.exception)) + self.assertIsNotNone(raised.exception.__cause__) + + def test_gate_rejects_a_non_list_or_non_object_results_collection(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name, results in (("mapping", {}), ("scalar", ["warning"])): + report = root / f"{name}.sarif" + write_sarif(report, [{"results": results}]) + with self.subTest(results=results), self.assertRaisesRegex( + SarifError, "results must be a list of objects" + ): + require_clean((report,)) + + def test_module_entry_point_exits_successfully_for_a_clean_gate(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + report = root / "clean.sarif" + write_sarif(report, [{"results": []}]) + with ( + mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(root)}), + mock.patch.object(sys, "argv", ["sarif.py", "gate", str(report)]), + self.assertWarnsRegex(RuntimeWarning, "core.sarif"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.sarif", run_name="__main__") + + self.assertEqual(raised.exception.code, 0) + + +class FakeProcess: + def __init__( + self, + *, + communicate_error: BaseException | None = None, + leave_returncode_unset: bool = False, + kill_error: BaseException | None = None, + ) -> None: + self._handle = 301 + self.returncode: int | None = None + self.communicate_error = communicate_error + self.leave_returncode_unset = leave_returncode_unset + self.kill_error = kill_error + self.started = threading.Event() + self.release = threading.Event() + self.release.set() + self.events: list[str] = [] + + def resume(self) -> None: + self.events.append("resume") + + def communicate(self, *, input: bytes) -> tuple[None, None]: + self.events.append(f"communicate:{input!r}") + self.started.set() + self.release.wait(timeout=5) + if self.communicate_error is not None: + raise self.communicate_error + if not self.leave_returncode_unset: + self.returncode = 0 + return None, None + + def kill(self) -> None: + self.events.append("kill") + self.release.set() + if self.kill_error is not None: + raise self.kill_error + + def wait(self) -> int: + self.events.append("wait") + self.returncode = 1 + return self.returncode + + +class FakeJob: + def __init__( + self, + process: FakeProcess, + *, + close_error: BaseException | None = None, + terminate_error: BaseException | None = None, + ) -> None: + self.process = process + self.close_error = close_error + self.terminate_error = terminate_error + + def assign_process(self, process_handle: int) -> None: + self.process.events.append(f"assign:{process_handle}") + + def close(self) -> None: + self.process.events.append("close") + self.process.release.set() + if self.close_error is not None: + raise self.close_error + + def terminate(self) -> None: + self.process.events.append("terminate") + self.process.release.set() + if self.terminate_error is not None: + raise self.terminate_error + + +class WindowsProcessCleanupTests(unittest.IsolatedAsyncioTestCase): + @staticmethod + def patched(process: FakeProcess, job: FakeJob): + return ( + mock.patch("core.windows_process.psutil.Popen", return_value=process), + mock.patch("core.windows_process.WindowsJob", return_value=job), + ) + + async def test_communication_failure_during_reaping_is_reported(self) -> None: + process = FakeProcess(communicate_error=OSError("late pipe failure")) + communication = asyncio.create_task( + asyncio.to_thread(process.communicate, input=b"recipe") + ) + primary = RuntimeError("runner failed") + errors: list[BaseException] = [] + + await _reap(process, communication, errors, primary) + + self.assertEqual([str(error) for error in errors], ["late pipe failure"]) + self.assertIn("wait", process.events) + + async def test_successful_communication_must_set_an_exit_code(self) -> None: + process = FakeProcess(leave_returncode_unset=True) + job = FakeJob(process) + popen, windows_job = self.patched(process, job) + with popen, windows_job, self.assertRaisesRegex( + RuntimeError, "completed without an exit code" + ): + await WindowsProcessRunner().run( + Command((sys.executable,), stdin=b"recipe"), log=io.BytesIO() + ) + + self.assertIn("wait", process.events) + + async def test_every_cleanup_failure_is_visible_after_process_success(self) -> None: + process = FakeProcess(kill_error=OSError("root kill failed")) + job = FakeJob( + process, + close_error=OSError("job close failed"), + terminate_error=OSError("job terminate failed"), + ) + popen, windows_job = self.patched(process, job) + with popen, windows_job, self.assertRaisesRegex( + OSError, "job close failed" + ) as raised: + await WindowsProcessRunner().run( + Command((sys.executable,)), log=io.BytesIO() + ) + + self.assertEqual(process.events[-3:], ["close", "terminate", "kill"]) + notes = raised.exception.__notes__ + self.assertTrue(any("job terminate failed" in note for note in notes)) + self.assertTrue(any("root kill failed" in note for note in notes)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_coverage_config.py b/build/tests/test_coverage_config.py new file mode 100644 index 0000000..d15d128 --- /dev/null +++ b/build/tests/test_coverage_config.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from coverage import Coverage + + +BUILD_ROOT = Path(__file__).resolve().parents[1] + + +class CoverageConfigTests(unittest.TestCase): + def test_first_party_python_gate_requires_every_line_and_branch(self) -> None: + coverage = Coverage(config_file=str(BUILD_ROOT / "pyproject.toml")) + + self.assertTrue(coverage.get_option("run:branch")) + self.assertEqual(coverage.get_option("run:source"), ["."]) + self.assertEqual( + coverage.get_option("run:command_line"), + "-m unittest discover -s tests -p test_*.py", + ) + self.assertEqual(coverage.get_option("report:fail_under"), 100) + self.assertEqual(coverage.get_option("report:exclude_lines"), []) + self.assertEqual( + coverage.get_option("report:include"), + ["core/*", "graphs/*", "driver.py", "main.py"], + ) + self.assertTrue(coverage.get_option("report:show_missing")) + self.assertFalse(coverage.get_option("report:skip_covered")) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_cpp_coverage_graph.py b/build/tests/test_cpp_coverage_graph.py new file mode 100644 index 0000000..287039a --- /dev/null +++ b/build/tests/test_cpp_coverage_graph.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +from contextlib import redirect_stderr +import hashlib +import io +import json +from pathlib import Path +import runpy +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.cpp_coverage import CoverageError, main as coverage_main, require_full_coverage # noqa: E402 +from core.graph import Command, Graph, Node # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from graphs.coverage import ( # noqa: E402 + coverage_artifact_graph, + coverage_dependency_discovery_slice, + coverage_graph, +) +from tests import test_instrumented_graph as instrumented_fixture # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "build", + Command(("C:/sdk/build.exe",)), + inputs, + ) + + +class CppCoverageGraphTests(unittest.TestCase): + def fixture( + self, root: Path, architectures: tuple[str, ...] = ("x64", "x86") + ) -> tuple[Path, Graph, dict[str, Path]]: + repository = root / "repo" + repository.mkdir() + nodes = [] + for architecture in architectures: + restore = node(f"restore-vcpkg-{architecture}") + discovery = node(f"coverage-dependencies-{architecture}", inputs=(restore.name,)) + nodes.extend((restore, discovery)) + for name, filename in ( + ("renpy", "renpy.so"), + ("rpgmaker", "rpgmaker.so"), + ("zanzarah", "zanzarah.so"), + ("tests", "tests.exe"), + ): + producer = node( + f"build-{name}-{architecture}-coverage", inputs=(discovery.name,) + ) + nodes.append(producer) + upstream = Graph(tuple(nodes), tuple(item.name for item in nodes[1:]), {"build": 8}) + tools = root / "tools" + tools.mkdir() + selected = { + name: tools / name + for name in ("pwsh.exe", "llvm-profdata.exe", "llvm-cov.exe") + } + for path in selected.values(): + path.touch() + return repository, upstream, selected + + def graph(self, root: Path, **options: object) -> Graph: + architectures = options.pop("architectures", ("x64", "x86")) + repository, upstream, tools = self.fixture(root, architectures) # type: ignore[arg-type] + return coverage_artifact_graph( + repository, + upstream, + architectures=architectures, # type: ignore[arg-type] + pwsh=tools["pwsh.exe"], + pwsh_identity={"version": options.pop("pwsh_version", "7.5")}, + llvm_profdata=tools["llvm-profdata.exe"], + llvm_profdata_identity={"version": options.pop("profdata_version", "20.1")}, + llvm_cov=tools["llvm-cov.exe"], + llvm_cov_identity={"version": options.pop("cov_version", "20.1")}, + **options, + ) + + def test_build_adapter_shards_merge_parallel_reports_and_hard_gate_form_the_dag( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + graph = self.graph(root, test_shards=2, jobs=6, report_jobs=3) + paths = BuildPaths(root / "repo") + + self.assertEqual( + graph.pools, + { + "build": 8, + "coverage-shard": 6, + "coverage-merge": 2, + "coverage-report": 3, + "coverage-gate": 6, + }, + ) + self.assertEqual(graph.targets, ("coverage-x64", "coverage-x86")) + self.assertEqual(len(graph.nodes), 24) + for architecture in ("x64", "x86"): + builds = tuple( + graph.node(f"build-{name}-{architecture}-coverage") + for name in ("renpy", "rpgmaker", "zanzarah", "tests") + ) + shards = tuple( + graph.node(f"coverage-test-{architecture}-{index}") for index in range(2) + ) + for index, shard in enumerate(shards): + self.assertEqual(shard.inputs, tuple(item.name for item in builds)) + self.assertEqual(shard.pool, "coverage-shard") + script = shard.command.stdin.decode("utf-8") + self.assertIn("LLVM_PROFILE_FILE", script) + self.assertIn("coverage-%m-%p.profraw", script) + self.assertIn("'--shard-count'", script) + self.assertIn("'2'", script) + self.assertIn("'--shard-index'", script) + self.assertIn(f"'{index}'", script) + self.assertEqual( + tuple((item.id, item.relative_path) for item in shard.results), + (( + f"reports/coverage/cpp/{architecture}/tests/shard-{index}.xml", + "tests.xml", + ),), + ) + + merge = graph.node(f"coverage-merge-{architecture}") + self.assertEqual(merge.inputs, tuple(item.name for item in shards)) + self.assertEqual(merge.pool, "coverage-merge") + merge_script = merge.command.stdin.decode("utf-8") + self.assertIn("llvm-profdata.exe", merge_script) + self.assertIn("coverage.profdata", merge_script) + self.assertIn("$arguments = @('merge', '-sparse') + $profiles", merge_script) + self.assertIn("llvm-profdata.exe' $arguments", merge_script) + for shard in shards: + self.assertIn(str(paths.cas(shard.uid).output), merge_script) + + reports = tuple( + graph.node(f"coverage-{kind}-{architecture}") for kind in ("json", "lcov") + ) + for report in reports: + self.assertEqual(report.inputs, (merge.name, *(item.name for item in builds))) + self.assertEqual(report.pool, "coverage-report") + script = report.command.stdin.decode("utf-8") + self.assertIn("llvm-cov.exe", script) + self.assertIn("--instr-profile", script) + self.assertIn("--ignore-filename-regex", script) + for module in ("renpy.so", "rpgmaker.so", "zanzarah.so"): + self.assertIn(module, script) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in reports[0].results), + (( + f"reports/coverage/cpp/{architecture}/coverage.json", + "coverage", "application/json", "coverage.json", + ),), + ) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in reports[1].results), + (( + f"reports/coverage/cpp/{architecture}/coverage.lcov", + "coverage", "text/plain", "coverage.lcov", + ),), + ) + self.assertEqual(merge.results, ()) + self.assertIn("--summary-only", reports[0].command.stdin.decode("utf-8")) + self.assertIn("--format=lcov", reports[1].command.stdin.decode("utf-8")) + + gate = graph.node(f"coverage-{architecture}") + self.assertEqual(gate.inputs, tuple(item.name for item in reports)) + self.assertEqual(gate.pool, "coverage-gate") + self.assertEqual(gate.command.argv[1:4], ("-m", "core.cpp_coverage", "gate")) + self.assertNotIn("threshold", " ".join(gate.command.argv).casefold()) + self.assertEqual(gate.results, ()) + + def test_tool_identities_invalidate_only_their_nodes_and_semantic_consumers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, tools = self.fixture(root, ("x64",)) + + def build(profdata_version: str, cov_version: str) -> Graph: + return coverage_artifact_graph( + repository, + upstream, + architectures=("x64",), + pwsh=tools["pwsh.exe"], + pwsh_identity={"version": "7.5"}, + llvm_profdata=tools["llvm-profdata.exe"], + llvm_profdata_identity={"version": profdata_version}, + llvm_cov=tools["llvm-cov.exe"], + llvm_cov_identity={"version": cov_version}, + test_shards=2, + ) + + before, profdata, cov = build("20.1", "20.1"), build("20.2", "20.1"), build("20.1", "20.2") + + for current in before.nodes: + if not current.name.startswith("coverage-") or current.name.startswith( + "coverage-dependencies-" + ): + continue + profdata_partition = not current.name.startswith("coverage-test-") + cov_partition = current.name.startswith(("coverage-json-", "coverage-lcov-")) or current.name == "coverage-x64" + self.assertEqual(profdata_partition, current.uid != profdata.node(current.name).uid, current.name) + self.assertEqual(cov_partition, current.uid != cov.node(current.name).uid, current.name) + + def test_external_corpus_adds_independent_profile_shards_and_signs_only_path_and_nonce(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, tools = self.fixture(root, ("x64",)) + corpus = root / "corpus" + moved_corpus = root / "moved-corpus" + corpus.mkdir() + moved_corpus.mkdir() + + def build(selected: Path | None = None, nonce: str = "") -> Graph: + return coverage_artifact_graph( + repository, + upstream, + architectures=("x64",), + pwsh=tools["pwsh.exe"], + pwsh_identity={"version": "7.5"}, + llvm_profdata=tools["llvm-profdata.exe"], + llvm_profdata_identity={"version": "20.1"}, + llvm_cov=tools["llvm-cov.exe"], + llvm_cov_identity={"version": "20.1"}, + test_shards=2, + corpus=selected, + run_nonce=nonce, + ) + + baseline = build() + no_corpus = build(None, "ignored") + first = build(corpus, "run-one") + (corpus / "multi-gigabyte-placeholder.bin").write_bytes(b"not signed") + content_changed = build(corpus, "run-one") + rerun = build(corpus, "run-two") + moved = build(moved_corpus, "run-one") + cas_outputs = { + name: BuildPaths(repository).cas(first.node(name).uid).output + for name in ( + *(f"coverage-test-x64-{index}" for index in range(2)), + *(f"coverage-corpus-x64-{index}" for index in range(2)), + ) + } + + standard_names = tuple(f"coverage-test-x64-{index}" for index in range(2)) + corpus_names = tuple(f"coverage-corpus-x64-{index}" for index in range(2)) + for name in standard_names: + self.assertEqual(baseline.node(name), no_corpus.node(name)) + self.assertEqual(baseline.node(name), first.node(name)) + self.assertNotIn("OBSERVER_TEST_CORPUS", dict(first.node(name).command.env)) + self.assertFalse(any(node.name.startswith("coverage-corpus-") for node in baseline.nodes)) + for index, name in enumerate(corpus_names): + shard = first.node(name) + self.assertEqual(shard.uid, content_changed.node(name).uid) + self.assertNotEqual(shard.uid, rerun.node(name).uid) + self.assertNotEqual(shard.uid, moved.node(name).uid) + self.assertEqual(shard.inputs, first.node(standard_names[index]).inputs) + self.assertEqual( + dict(shard.command.env)["OBSERVER_TEST_CORPUS"], str(corpus.resolve()) + ) + script = shard.command.stdin.decode("utf-8") + self.assertIn("'[compatibility]'", script) + self.assertIn("LLVM_PROFILE_FILE", script) + self.assertEqual( + tuple((item.id, item.relative_path) for item in shard.results), + (( + f"reports/coverage/cpp/x64/corpus/shard-{index}.xml", + "tests.xml", + ),), + ) + merge = first.node("coverage-merge-x64") + self.assertEqual(merge.inputs, standard_names + corpus_names) + for name in (*standard_names, *corpus_names): + self.assertIn(str(cas_outputs[name]), merge.command.stdin.decode()) + self.assertTrue(all( + "OBSERVER_TEST_CORPUS" not in dict(node.command.env) + for node in first.nodes if not node.name.startswith("coverage-corpus-") + )) + + def test_rejects_invalid_axes_lineage_tools_pools_and_corpus(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, tools = self.fixture(root, ("x64",)) + + def invoke( + source: Graph = upstream, + architectures: tuple[str, ...] = ("x64",), + **options: object, + ) -> Graph: + return coverage_artifact_graph( + repository, + source, + architectures=architectures, + pwsh=options.pop("pwsh", tools["pwsh.exe"]), # type: ignore[arg-type] + pwsh_identity={"version": "7.5"}, + llvm_profdata=tools["llvm-profdata.exe"], + llvm_profdata_identity={"version": "20.1"}, + llvm_cov=tools["llvm-cov.exe"], + llvm_cov_identity={"version": "20.1"}, + **options, + ) + + for invalid in ((), ("x64", "x64"), ("armv7",)): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "architectures"): + invoke(architectures=invalid) + + missing = Graph( + tuple(item for item in upstream.nodes if item.name != "build-tests-x64-coverage"), + tuple(name for name in upstream.targets if name != "build-tests-x64-coverage"), + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "unknown node"): + invoke(missing) + + shared = node("detached-shared") + left = node("detached-left", inputs=(shared.name,)) + right = node("detached-right", inputs=(shared.name,)) + detached = node("build-renpy-x64-coverage", inputs=(left.name, right.name)) + detached_upstream = Graph( + tuple( + detached if current.name == detached.name else current + for current in upstream.nodes + ) + (shared, left, right), + upstream.targets, + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "restore ancestor"): + invoke(detached_upstream) + + for options in ({"test_shards": 0}, {"jobs": True}, {"report_jobs": 0}): + with self.subTest(options=options), self.assertRaisesRegex(ValueError, "positive integer"): + invoke(**options) + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(pwsh=tools["pwsh.exe"].parent) + conflicting = Graph( + upstream.nodes, + upstream.targets, + dict(upstream.pools) | {"coverage-report": 1}, + ) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + invoke(conflicting, report_jobs=2) + corpus = root / "corpus" + corpus.mkdir() + for nonce in ("", True, "bad\0nonce"): + with self.subTest(nonce=nonce), self.assertRaisesRegex(ValueError, "run nonce"): + invoke(corpus=corpus, run_nonce=nonce) + with self.assertRaises(FileNotFoundError): + invoke(corpus=root / "missing", run_nonce="run") + file_corpus = root / "corpus.bin" + file_corpus.touch() + with self.assertRaises(NotADirectoryError): + invoke(corpus=file_corpus, run_nonce="run") + + def test_complete_graph_discovers_and_builds_coverage_artifacts_itself(self) -> None: + helper = instrumented_fixture.InstrumentedBuildGraphTests() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = helper.repository(root / "repo") + toolchain = helper.toolchain(root) + for name in ("llvm-cov.exe", "llvm-profdata.exe"): + (toolchain.llvm_dir / "bin" / name).write_bytes(b"llvm-v1") + discovery = coverage_dependency_discovery_slice( + repository, toolchain, jobs=4, architectures=("x64",) + ) + graph = coverage_graph( + repository, + toolchain, + discovery=discovery, + manifests=helper.manifests(repository, discovery), + jobs=4, + architectures=("x64",), + test_shards=1, + ) + corpus = root / "corpus" + corpus.mkdir() + corpus_graph = coverage_graph( + repository, + toolchain, + discovery=discovery, + manifests=helper.manifests(repository, discovery), + jobs=4, + architectures=("x64",), + test_shards=1, + corpus=corpus, + run_nonce="high-level-run", + ) + + self.assertEqual(graph.targets, ("coverage-x64",)) + self.assertEqual( + len(graph.nodes), + len(discovery.nodes) + 4 + 1 + 1 + 2 + 1, + ) + shard = graph.node("coverage-test-x64-0") + self.assertIn("coverage-corpus-x64-0", tuple(node.name for node in corpus_graph.nodes)) + for project in ("renpy", "rpgmaker", "zanzarah", "tests"): + build = graph.node(f"build-{project}-x64-coverage") + self.assertIn("'/p:Configuration=Coverage'", build.command.stdin.decode()) + self.assertEqual(shard.inputs.count(build.name), 1) + + def test_complete_graph_hashes_exact_llvm_report_tool_bytes(self) -> None: + helper = instrumented_fixture.InstrumentedBuildGraphTests() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = helper.repository(root / "repo") + toolchain = helper.toolchain(root) + cov = toolchain.llvm_dir / "bin/llvm-cov.exe" + profdata = toolchain.llvm_dir / "bin/llvm-profdata.exe" + cov.write_bytes(b"cov-v1") + profdata.write_bytes(b"profdata-v1") + discovery = coverage_dependency_discovery_slice(repository, toolchain, jobs=4) + manifests = helper.manifests(repository, discovery) + + def build() -> Graph: + return coverage_graph( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + test_shards=1, + ) + + before = build() + profdata.write_bytes(b"profdata-v2") + profdata_changed = build() + profdata.write_bytes(b"profdata-v1") + cov.write_bytes(b"cov-v2") + cov_changed = build() + + for name in ( + "coverage-test-x64-0", + "coverage-merge-x64", + "coverage-json-x64", + "coverage-lcov-x64", + "coverage-x64", + ): + self.assertEqual( + name != "coverage-test-x64-0", + before.node(name).uid != profdata_changed.node(name).uid, + name, + ) + self.assertEqual( + name.startswith(("coverage-json-", "coverage-lcov-")) + or name == "coverage-x64", + before.node(name).uid != cov_changed.node(name).uid, + name, + ) + + +class CppCoverageGateTests(unittest.TestCase): + @staticmethod + def report(lines: tuple[int, int] = (7, 7), branches: tuple[int, int] = (3, 3)) -> dict[str, object]: + return { + "type": "llvm.coverage.json.export", + "data": [{"totals": { + "lines": {"count": lines[0], "covered": lines[1]}, + "branches": {"count": branches[0], "covered": branches[1]}, + }}], + } + + def test_gate_requires_nonempty_exactly_complete_first_party_lines_and_branches(self) -> None: + require_full_coverage(self.report()) + for document, message in ( + (self.report(lines=(7, 6)), "lines"), + (self.report(branches=(3, 2)), "branches"), + (self.report(branches=(0, 0)), "no first-party branches"), + (self.report(lines=(0, 0)), "no first-party lines"), + ): + with self.subTest(message=message), self.assertRaisesRegex(CoverageError, message): + require_full_coverage(document) + + def test_gate_rejects_malformed_or_ambiguous_llvm_reports(self) -> None: + malformed = ( + {}, + {"data": []}, + {"data": [{"totals": {}}, {"totals": {}}]}, + {"data": [{"totals": []}]}, + {"data": [{"totals": {"lines": [], "branches": {"count": 1, "covered": 1}}}]}, + {"data": [{"totals": {"lines": {"count": True, "covered": 1}, "branches": {"count": 1, "covered": 1}}}]}, + {"data": [{"totals": {"lines": {"count": 1, "covered": 2}, "branches": {"count": 1, "covered": 1}}}]}, + ) + for document in malformed: + with self.subTest(document=document), self.assertRaisesRegex(CoverageError, "malformed"): + require_full_coverage(document) + + def test_cli_reads_json_without_a_threshold_override(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + report = Path(temporary) / "coverage.json" + report.write_text(json.dumps(self.report()), encoding="utf-8") + self.assertEqual(coverage_main(("gate", str(report))), 0) + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + coverage_main(("gate", str(report), "99")) + + with ( + mock.patch.object(sys, "argv", ["cpp_coverage.py", "gate", str(report)]), + self.assertWarnsRegex(RuntimeWarning, "core.cpp_coverage"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.cpp_coverage", run_name="__main__") + self.assertEqual(raised.exception.code, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_doctor.py b/build/tests/test_doctor.py new file mode 100644 index 0000000..fd00c33 --- /dev/null +++ b/build/tests/test_doctor.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +import runpy +from types import SimpleNamespace +import sys +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +import core.doctor as doctor # noqa: E402 + + +TOOLCHAIN = SimpleNamespace(identity=( + ("msbuild_version", "17.14"), ("vc_tools_version", "14.44"), + ("clang_tidy_version", "19.1"), ("windows_sdk_version", "10.0"), +)) +SOURCE = SimpleNamespace(identity=( + ("pwsh_version", "7.5"), ("clang_format_version", "19.1"), + ("cppcheck_version", "2.18"), ("psscriptanalyzer_version", "1.24"), +)) +QUALITY = object() +RUNTIMES = object() + + +class DoctorTests(unittest.TestCase): + def patches(self) -> tuple[mock._patch, ...]: + return ( + mock.patch.object(doctor, "discover_msvc_toolchain", return_value=TOOLCHAIN), + mock.patch.object(doctor, "discover_source_tools", return_value=SOURCE), + mock.patch.object(doctor, "discover_quality_tools", return_value=QUALITY), + mock.patch.object(doctor, "resolve_sanitizer_runtimes", return_value=RUNTIMES), + ) + + def test_complete_report_is_deterministic_and_main_renders_plain_tsv(self) -> None: + with self.patches()[0] as msvc, self.patches()[1] as source, \ + self.patches()[2] as quality, self.patches()[3] as runtimes: + report = doctor.doctor_report() + self.assertEqual(report, doctor.doctor_report()) + self.assertEqual( + report, + ( + doctor.Probe("python", "OK", "3.14.6"), + doctor.Probe("msvc", "OK", "MSBuild=17.14, MSVC=14.44, LLVM=19.1, SDK=10.0"), + doctor.Probe("source-tools", "OK", "PowerShell=7.5, clang-format=19.1, Cppcheck=2.18, PSScriptAnalyzer=1.24"), + doctor.Probe("quality-tools", "OK", "clang-cl, clang-scan-deps, llvm-cov, llvm-profdata, dumpbin, BinSkim, UMDH"), + doctor.Probe("sanitizer-runtimes", "OK", "ASan x86/x64, UBSan x64"), + ), + ) + self.assertEqual(msvc.call_count, 2) + self.assertEqual(source.call_args, mock.call(TOOLCHAIN)) + self.assertEqual(quality.call_args, mock.call(TOOLCHAIN)) + self.assertEqual(runtimes.call_args, mock.call(TOOLCHAIN)) + + output = StringIO() + with mock.patch.object(doctor, "doctor_report", return_value=report), redirect_stdout(output): + self.assertEqual(doctor.main(()), 0) + self.assertEqual( + output.getvalue(), + "probe\tstatus\tdetail\n" + "".join( + f"{item.name}\t{item.status}\t{item.detail}\n" for item in report + ), + ) + + def test_failures_are_concise_independent_and_make_main_fail(self) -> None: + failing_quality = RuntimeError("quality\n missing") + with ( + mock.patch.object(doctor, "discover_msvc_toolchain", return_value=TOOLCHAIN), + mock.patch.object(doctor, "discover_source_tools", return_value=SOURCE), + mock.patch.object(doctor, "discover_quality_tools", side_effect=failing_quality), + mock.patch.object(doctor, "resolve_sanitizer_runtimes", return_value=RUNTIMES), + ): + report = doctor.doctor_report() + self.assertEqual([item.status for item in report], ["OK", "OK", "OK", "MISSING", "OK"]) + self.assertEqual(report[3].detail, "quality missing") + output = StringIO() + with mock.patch.object(doctor, "doctor_report", return_value=report), redirect_stdout(output): + self.assertEqual(doctor.main(()), 1) + self.assertIn("quality-tools\tMISSING\tquality missing\n", output.getvalue()) + + def test_missing_python_and_msvc_still_report_every_probe(self) -> None: + missing = RuntimeError() + with ( + mock.patch.object(doctor.sys, "version_info", (3, 14, 5)), + mock.patch.object(doctor, "discover_msvc_toolchain", side_effect=missing), + mock.patch.object(doctor, "discover_source_tools") as source, + mock.patch.object(doctor, "discover_quality_tools") as quality, + mock.patch.object(doctor, "resolve_sanitizer_runtimes") as runtimes, + ): + report = doctor.doctor_report() + self.assertEqual([item.status for item in report], ["MISSING"] * 5) + self.assertIn("requires ==3.14.6, running 3.14.5", report[0].detail) + self.assertEqual(report[1].detail, "RuntimeError") + self.assertTrue(all(item.detail == "MSVC toolchain unavailable" for item in report[2:])) + source.assert_not_called() + quality.assert_not_called() + runtimes.assert_not_called() + + def test_module_entry_point_exits_with_main_result(self) -> None: + patches = ( + mock.patch("core.toolchain.discover_msvc_toolchain", return_value=TOOLCHAIN), + mock.patch("core.source_tools.discover_source_tools", return_value=SOURCE), + mock.patch("core.quality_tools.discover_quality_tools", return_value=QUALITY), + mock.patch("core.quality_tools.resolve_sanitizer_runtimes", return_value=RUNTIMES), + mock.patch.object(sys, "argv", ["doctor.py"]), + ) + with patches[0], patches[1], patches[2], patches[3], patches[4], \ + redirect_stdout(StringIO()), self.assertRaises(SystemExit) as raised: + runpy.run_path(str(BUILD_ROOT / "core/doctor.py"), run_name="__main__") + self.assertEqual(raised.exception.code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_driver.py b/build/tests/test_driver.py new file mode 100644 index 0000000..913f588 --- /dev/null +++ b/build/tests/test_driver.py @@ -0,0 +1,883 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from types import SimpleNamespace +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Graph, GraphError, Node, Result # noqa: E402 +from core.quality_tools import ResolvedDirectory, ResolvedTool # noqa: E402 +import driver # noqa: E402 + + +MODULES = ("renpy", "rpgmaker", "zanzarah") + + +def node( + name: str, *, inputs: tuple[str, ...] = (), results: tuple[Result, ...] = () +) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "slot", + Command(("true",)), + inputs, + results, + ) + + +def discovery_graph(architectures: tuple[str, ...]) -> Graph: + restores = tuple(node(f"restore-vcpkg-{architecture}") for architecture in architectures) + discovered = tuple( + node(f"discover-{architecture}", inputs=(restore.name,)) + for architecture, restore in zip(architectures, restores, strict=True) + ) + return Graph(restores + discovered, tuple(item.name for item in discovered), {"restore": 1, "slot": 4}) + + +def native_result(_repository: Path, _toolchain: object, **options: object) -> Graph: + discovery = options["discovery"] + architectures = options["architectures"] + configurations = options["configurations"] + builds = tuple( + node(f"build-{project}-{architecture}-{configuration.lower()}") + for architecture in architectures + for configuration in configurations + for project in (*MODULES, "tests") + ) + if options["include_leak_probe"]: + builds += (node("build-leak-probe-x64-release"),) + return Graph(discovery.nodes + builds, tuple(item.name for item in builds), {"restore": 1, "slot": 4}) + + +class FakeRuntime: + def __init__(self, repository: Path, run_id: str) -> None: + self.repository = repository + self.run_id = run_id + self.session_active = False + self.executed: list[Graph] = [] + self.store = SimpleNamespace( + paths_for=lambda current: SimpleNamespace( + output=repository / "out/cas" / current.name / "out" + ) + ) + + def executor(self, graph: Graph) -> FakeRuntime: + self.executed.append(graph) + return self + + async def run(self) -> None: + return None + + def session(self) -> FakeRuntime: + return self + + async def __aenter__(self) -> FakeRuntime: + self.session_active = True + return self + + async def __aexit__(self, *_exc: object) -> None: + self.session_active = False + + @property + def owned_run_id(self) -> str | None: + return self.run_id if self.session_active else None + + def cache_report(self, _graph: Graph | None = None) -> dict[str, object]: + return { + "schema": 1, + "summary": {"executed": 0, "failed": 0, "hit": 0, "incomplete": 0}, + "nodes": [], + } + + def live_uids(self, graph: Graph) -> tuple[str, ...]: + return tuple(sorted(current.uid for current in graph.nodes)) + + +class DriverTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.repository = Path(self.temporary.name) / "repo" + self.repository.mkdir() + self.toolchain = SimpleNamespace(identity=(), environment=()) + self.runtime_patch = mock.patch.object(driver, "BuildRuntime", FakeRuntime) + self.runtime_patch.start() + + def tearDown(self) -> None: + self.runtime_patch.stop() + self.temporary.cleanup() + + def session( + self, jobs: int | None = 4, *, prune_cas: bool = False + ) -> driver.Driver: + return driver.Driver( + self.repository, "run-1", self.toolchain, + jobs=jobs, prune_cas=prune_cas, + ) + + def tool(self, name: str) -> ResolvedTool: + path = self.repository / f"tools/{name}.exe" + return ResolvedTool(path, (("name", name),)) + + @mock.patch.object(driver.psutil, "cpu_count", return_value=None) + def test_session_owns_one_canonical_repository_runtime_and_validates_jobs( + self, cpu_count: mock.Mock + ) -> None: + session = self.session(None) + self.assertEqual(session.repository, self.repository.resolve()) + self.assertEqual(session.jobs, 1) + self.assertEqual(session.runtime.run_id, "run-1") + cpu_count.assert_called_once_with() + with self.assertRaisesRegex(ValueError, "positive"): + self.session(0) + with self.assertRaisesRegex(ValueError, "positive"): + self.session(True) + + async def test_restore_build_test_source_analysis_and_fuzz_use_shared_staging(self) -> None: + session = self.session() + source_tools = object() + corpus = self.repository / "corpus" + corpus.mkdir() + + def restore_node(_repository: Path, _toolchain: object, _factory: object, + architecture: str, *, flavor: str = "") -> Node: + suffix = f"-{flavor}" if flavor else "" + name = f"restore-vcpkg{suffix}-{architecture}" + return Node( + name, + hashlib.md5(f"{flavor}-{architecture}".encode(), usedforsecurity=False).hexdigest(), + "restore", Command(("true",)), (), + ) + + with ( + mock.patch.object(driver, "restore_node", side_effect=restore_node) as restore, + mock.patch.object( + driver, "native_dependency_discovery_slice", + side_effect=lambda _repository, _toolchain, **options: discovery_graph(options["architectures"]), + ) as native_discovery, + mock.patch.object(driver, "native_graph", side_effect=native_result) as native, + mock.patch.object(driver, "load_dependency_manifests", return_value={"unit": b"{}"}) as manifests, + mock.patch.object( + driver, "source_checks_graph", return_value=Graph((node("source"),), ("source",), {"slot": 4}) + ) as source, + mock.patch.object( + driver, "analysis_discovery_slice", return_value=discovery_graph(("x64",)) + ) as analysis_discovery, + mock.patch.object( + driver, "analysis_slice", return_value=Graph((node("analysis"),), ("analysis",), {"slot": 4}) + ) as analysis, + mock.patch.object( + driver, "fuzz_dependency_discovery_slice", return_value=discovery_graph(("x64",)) + ) as fuzz_discovery, + mock.patch.object( + driver, "fuzz_graph", return_value=Graph((node("fuzz"),), ("fuzz",), {"slot": 2}) + ) as fuzz, + mock.patch.object(driver, "require_runnable", side_effect=lambda value: value) as require, + ): + await session.restore(("x64",), ("", "asan")) + await session.build(("x64",), ("Debug", "Release")) + await session.test(("x64",), ("Debug",), test_shards=7, corpus=corpus, run_nonce="nonce") + await session.source_checks(("x64",), source_tools) + await session.compiler_analysis(("x64",)) + await session.fuzz( + run_nonce="fuzz-run", seconds=9, fuzz_jobs=3, + targets=("pickle", "renpy"), + ) + + self.assertEqual(restore.call_count, 2) + self.assertEqual( + session.runtime.executed[0].targets, + ("restore-vcpkg-x64", "restore-vcpkg-asan-x64"), + ) + self.assertEqual(native_discovery.call_count, 2) + self.assertEqual(manifests.call_count, 4) + self.assertEqual(native.call_args_list[0].kwargs["runnable_architectures"], ()) + self.assertFalse(native.call_args_list[0].kwargs["include_leak_probe"]) + self.assertEqual(native.call_args_list[1].kwargs["runnable_architectures"], ("x64",)) + self.assertEqual(native.call_args_list[1].kwargs["test_shards"], 7) + self.assertEqual(native.call_args_list[1].kwargs["corpus"], corpus) + self.assertEqual(native.call_args_list[1].kwargs["run_nonce"], "nonce") + source.assert_called_once_with(self.repository, source_tools, jobs=4, architectures=("x64",)) + analysis_discovery.assert_called_once() + self.assertEqual(analysis.call_args.kwargs["manifests"], {"unit": b"{}"}) + fuzz_discovery.assert_called_once() + self.assertEqual(fuzz.call_args.kwargs["run_nonce"], "fuzz-run") + self.assertEqual(fuzz.call_args.kwargs["seconds"], 9) + self.assertEqual(fuzz.call_args.kwargs["fuzz_jobs"], 3) + self.assertEqual(fuzz_discovery.call_args.kwargs["targets"], ("pickle", "renpy")) + self.assertEqual(fuzz.call_args.kwargs["targets"], ("pickle", "renpy")) + self.assertEqual(require.call_args_list, [mock.call(("x64",)), mock.call(("x64",))]) + + async def test_coverage_sanitizers_and_python_coverage_use_staged_graph_apis(self) -> None: + session = self.session() + corpus = self.repository / "corpus" + corpus.mkdir() + asan_tools = tuple((architecture, self.tool(f"asan-{architecture}")) + for architecture in ("x86", "x64")) + ubsan_file = self.tool("ubsan") + ubsan = ResolvedDirectory(self.repository / "ubsan", (ubsan_file,)) + + with ( + mock.patch.object(driver, "coverage_dependency_discovery_slice", + return_value=discovery_graph(("x64",))) as coverage_discovery, + mock.patch.object(driver, "coverage_graph", + return_value=Graph((node("coverage"),), ("coverage",), {"slot": 4})) as coverage, + mock.patch.object(driver, "sanitizer_dependency_discovery_slice", + side_effect=lambda *_args, **_kwargs: discovery_graph(("x64",))) as sanitizer_discovery, + mock.patch.object(driver, "sanitizer_graph", + side_effect=lambda *_args, **kwargs: Graph( + (node(kwargs["selections"][0][0]),), + (kwargs["selections"][0][0],), {"slot": 4} + )) as sanitizer, + mock.patch.object(driver, "python_coverage_graph", + return_value=Graph((node("python"),), ("python",), {"slot": 1})) as python_graph, + mock.patch.object(driver, "resolve_asan_runtimes", return_value=asan_tools) as resolve_asan, + mock.patch.object(driver, "resolve_ubsan_runtime", return_value=ubsan) as resolve_ubsan, + mock.patch.object(driver, "load_dependency_manifests", return_value={"tu": b"{}"}) as manifests, + mock.patch.object(driver, "require_runnable", side_effect=lambda value: value) as require, + ): + await session.test_coverage( + ("x64",), test_shards=7, report_jobs=3, + corpus=corpus, run_nonce="coverage-run", + ) + await session.test_asan(("x86", "x64"), test_shards=5) + await session.test_ubsan(("x64",), test_shards=6) + await session.python_coverage() + + coverage_discovery.assert_called_once_with( + self.repository, self.toolchain, jobs=4, architectures=("x64",) + ) + self.assertEqual(coverage.call_args.kwargs["manifests"], {"tu": b"{}"}) + self.assertEqual(coverage.call_args.kwargs["test_shards"], 7) + self.assertEqual(coverage.call_args.kwargs["report_jobs"], 3) + self.assertEqual(coverage.call_args.kwargs["corpus"], corpus) + self.assertEqual(coverage.call_args.kwargs["run_nonce"], "coverage-run") + self.assertEqual(sanitizer_discovery.call_count, 2) + self.assertEqual(sanitizer.call_count, 2) + asan_call, ubsan_call = sanitizer.call_args_list + self.assertEqual(asan_call.kwargs["selections"], (("asan", "x86"), ("asan", "x64"))) + self.assertEqual(tuple(item.architecture for item in asan_call.kwargs["asan_runtimes"]), + ("x86", "x64")) + self.assertIsNone(asan_call.kwargs["llvm_runtime"]) + self.assertEqual(ubsan_call.kwargs["selections"], (("ubsan", "x64"),)) + self.assertEqual(ubsan_call.kwargs["llvm_runtime"], ubsan.path) + self.assertEqual(ubsan_call.kwargs["llvm_runtime_identity"], dict(ubsan.identity)) + resolve_asan.assert_called_once_with(self.toolchain, ("x86", "x64")) + resolve_ubsan.assert_called_once_with(self.toolchain) + self.assertEqual(manifests.call_count, 3) + self.assertEqual(require.call_args_list, + [mock.call(("x64",)), mock.call(("x86", "x64")), mock.call(("x64",))]) + python_graph.assert_called_once_with(self.repository) + + async def test_quality_commands_reject_invalid_contracts_before_discovery(self) -> None: + session = self.session() + corpus = self.repository / "corpus" + corpus.mkdir() + with ( + mock.patch.object(driver, "coverage_dependency_discovery_slice") as coverage_discovery, + mock.patch.object(driver, "sanitizer_dependency_discovery_slice") as sanitizer_discovery, + ): + for invalid_nonce in ("", "bad\0nonce", 7): + with self.subTest(run_nonce=invalid_nonce), self.assertRaisesRegex( + ValueError, "corpus run nonce" + ): + await session.test_coverage(corpus=corpus, run_nonce=invalid_nonce) + with mock.patch.object( + driver, "require_runnable", side_effect=RuntimeError("cannot run") + ), self.assertRaisesRegex(RuntimeError, "cannot run"): + await session.test_coverage(("x64",)) + with self.assertRaisesRegex(ValueError, "ASan does not support arm64"): + await session.test_asan(("arm64",)) + with self.assertRaisesRegex(ValueError, "UBSan does not support x86"): + await session.test_ubsan(("x86",)) + with self.assertRaisesRegex(ValueError, "exactly one architecture"): + await session.verify_arch(("x86", "x64"), run_nonce="verify-run") + coverage_discovery.assert_not_called() + sanitizer_discovery.assert_not_called() + + async def test_release_audit_package_and_leaks_pass_only_canonical_axes(self) -> None: + session = self.session() + dumpbin, binskim, umdh = (self.tool(name) for name in ("dumpbin", "binskim", "umdh")) + + def native_discovery(_repository: Path, _toolchain: object, **options: object) -> Graph: + return discovery_graph(options["architectures"]) + + def audit_result(_repository: Path, upstream: Graph, **options: object) -> Graph: + modules = (("leak-probe",) if options["include_leak_probe"] else ()) + MODULES + gates = tuple( + node( + f"audit-pe-{architecture}-{module}", + inputs=(f"build-{module}-{architecture}-release",), + ) + for architecture in options["architectures"] + for module in modules + ) + return Graph(upstream.nodes + gates, tuple(item.name for item in gates), upstream.pools | {"audit": 4}) + + package_result = Graph((node("package-manifest"),), ("package-manifest",), {"slot": 4}) + leak_result = Graph((node("leak"),), ("leak",), {"slot": 4}) + expected_packages = (self.repository / "one.zip",) + with ( + mock.patch.object(driver, "native_dependency_discovery_slice", side_effect=native_discovery), + mock.patch.object(driver, "native_graph", side_effect=native_result) as native, + mock.patch.object(driver, "load_dependency_manifests", return_value={}), + mock.patch.object(driver, "audit_graph", side_effect=audit_result) as audit, + mock.patch.object(driver, "package_graph", return_value=package_result) as package, + mock.patch.object(driver, "package_outputs", return_value=expected_packages) as outputs, + mock.patch.object(driver, "leak_graph", return_value=leak_result) as leak, + mock.patch.object(driver, "runnable_architectures", return_value=("x86", "x64")) as runnable, + mock.patch.object(driver, "require_runnable", return_value=("x64",)) as require, + ): + await session.audit(("x64",), dumpbin=dumpbin, binskim=binskim) + result = await session.package( + ("x86", "x64", "arm64"), dumpbin=dumpbin, binskim=binskim + ) + await session.test_leaks( + run_nonce="leak-run", dumpbin=dumpbin, binskim=binskim, umdh=umdh, + warmup=2, iterations=3, windows=4, tolerance_bytes=5, + ) + + self.assertEqual(result, expected_packages) + self.assertEqual(native.call_count, 3) + self.assertTrue(all(call.kwargs["configurations"] == ("Release",) for call in native.call_args_list)) + self.assertEqual([call.kwargs["include_leak_probe"] for call in native.call_args_list], [False, False, True]) + self.assertTrue(all(call.kwargs["runnable_architectures"] == () for call in native.call_args_list)) + self.assertEqual(audit.call_count, 3) + self.assertTrue(all(len(call.args) == 2 for call in audit.call_args_list)) + self.assertEqual( + [call.kwargs["architectures"] for call in audit.call_args_list], + [("x64",), ("x86", "x64", "arm64"), ("x64",)], + ) + self.assertEqual( + [call.kwargs["include_leak_probe"] for call in audit.call_args_list], + [False, False, True], + ) + self.assertEqual(audit.call_args_list[0].kwargs["dumpbin_identity"], {"name": "dumpbin"}) + self.assertEqual(audit.call_args_list[0].kwargs["binskim_identity"], {"name": "binskim"}) + self.assertTrue(all(call.kwargs["jobs"] == 4 for call in audit.call_args_list)) + self.assertTrue(all("binskim_jobs" not in call.kwargs for call in audit.call_args_list)) + self.assertEqual(package.call_args.kwargs["architectures"], ("x86", "x64", "arm64")) + self.assertEqual(package.call_args.kwargs["smoke_architectures"], ("x86", "x64")) + runnable.assert_called_once_with(("x86", "x64", "arm64")) + outputs.assert_called_once_with(self.repository, package_result) + self.assertEqual(len(leak.call_args.args), 2) + self.assertEqual(leak.call_args.kwargs["umdh"], umdh.path) + self.assertEqual(leak.call_args.kwargs["umdh_identity"], {"name": "umdh"}) + self.assertEqual(leak.call_args.kwargs["run_nonce"], "leak-run") + self.assertEqual(leak.call_args.kwargs["warmup"], 2) + self.assertEqual(leak.call_args.kwargs["iterations"], 3) + self.assertEqual(leak.call_args.kwargs["windows"], 4) + self.assertEqual(leak.call_args.kwargs["tolerance_bytes"], 5) + self.assertEqual(leak.call_args.kwargs["jobs"], 4) + self.assertNotIn("session_jobs", leak.call_args.kwargs) + self.assertNotIn("diff_jobs", leak.call_args.kwargs) + require.assert_called_once_with(("x64",)) + + async def test_verify_runs_one_discovery_union_then_one_parallel_family_union(self) -> None: + session = self.session() + corpus = self.repository / "corpus" + corpus.mkdir() + dumpbin, binskim, umdh = (self.tool(name) for name in ("dumpbin", "binskim", "umdh")) + ubsan = ResolvedDirectory(self.repository / "ubsan", (self.tool("ubsan"),)) + route = SimpleNamespace( + runnable=("x86", "x64"), coverage=("x64",), asan=("x86", "x64"), + ubsan=("x64",), run_x64_specialists=True, + ) + + def discovered(_repository: Path, _toolchain: object, **options: object) -> Graph: + architectures = options.get("architectures", ("x64",)) + return discovery_graph(architectures) + + representative_results = { + "native": Result( + "reports/tests/x64/debug/unit/shard-0.xml", + "test", "application/xml", "tests.xml", + ), + "analysis": Result( + "reports/sarif/x64/analysis.sarif", + "report", "application/sarif+json", "analysis.sarif", + ), + "source": Result( + "reports/sarif/source/analysis.sarif", + "report", "application/sarif+json", "analysis.sarif", + ), + "cppcheck": Result( + "reports/sarif/x86-x64-arm64/cppcheck.sarif", + "report", "application/sarif+json", "analysis.sarif", + ), + "python": Result( + "reports/coverage/python/coverage.json", + "coverage", "application/json", "coverage.json", + ), + "coverage": Result( + "reports/coverage/cpp/x64/coverage.json", + "coverage", "application/json", "coverage.json", + ), + "sanitizer": Result( + "reports/sanitizers/asan/x64/shard-0.xml", + "test", "application/xml", "tests.xml", + ), + "fuzz": Result( + "reports/fuzz/x64/pickle/status.txt", + "fuzz", "text/plain", "status.txt", + ), + "audit": Result( + "reports/sarif/x64/binskim-renpy.sarif", + "report", "application/sarif+json", "binskim.sarif", + ), + "package": Result( + "packages/x64/renpy-x64-dll.zip", + "package", "application/zip", "renpy-x64-dll.zip", + ), + "leak": Result( + "reports/leak/x64/operations/small-success/summary.json", + "leak-summary", "application/json", "summary.json", + ), + } + + def family(name: str, upstream: Graph | None = None) -> Graph: + pools = dict(upstream.pools) if upstream else {"slot": 4} + nodes = upstream.nodes if upstream else () + marker = node(name, results=(representative_results[name],)) + return Graph(nodes + (marker,), (marker.name,), pools) + + def native_with_result( + repository: Path, toolchain: object, **options: object + ) -> Graph: + graph = native_result(repository, toolchain, **options) + marker = node("native-results", results=(representative_results["native"],)) + return Graph(graph.nodes + (marker,), graph.targets + (marker.name,), graph.pools) + + source_tools = mock.Mock(return_value="source-tools") + asan = mock.Mock(return_value=( + ("x86", self.tool("asan-x86")), ("x64", self.tool("asan-x64")), + )) + resolve_ubsan = mock.Mock(return_value=ubsan) + native = mock.Mock(side_effect=native_with_result) + common_source = mock.Mock(return_value=family("source")) + architecture_source = mock.Mock(return_value=family("cppcheck")) + coverage = mock.Mock(return_value=family("coverage")) + sanitizer = mock.Mock(return_value=family("sanitizer")) + fuzz = mock.Mock(return_value=family("fuzz")) + package = mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("package", upstream) + ) + leak = mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("leak", upstream) + ) + with mock.patch.multiple( + driver, + verify_route=mock.Mock(return_value=route), + discover_source_tools=source_tools, + resolve_dumpbin=mock.Mock(return_value=dumpbin), + resolve_binskim=mock.Mock(return_value=binskim), + resolve_umdh=mock.Mock(return_value=umdh), + resolve_asan_runtimes=asan, + resolve_ubsan_runtime=resolve_ubsan, + analysis_discovery_slice=mock.Mock(side_effect=discovered), + native_dependency_discovery_slice=mock.Mock(side_effect=discovered), + coverage_dependency_discovery_slice=mock.Mock(side_effect=discovered), + sanitizer_dependency_discovery_slice=mock.Mock(side_effect=discovered), + fuzz_dependency_discovery_slice=mock.Mock(side_effect=discovered), + load_dependency_manifests=mock.Mock(return_value={"tu": b"{}"}), + native_graph=native, + analysis_slice=mock.Mock(return_value=family("analysis")), + common_source_checks=common_source, + architecture_source_checks=architecture_source, + python_coverage_graph=mock.Mock(return_value=family("python")), + coverage_graph=coverage, + sanitizer_graph=sanitizer, + fuzz_graph=fuzz, + audit_graph=mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("audit", upstream) + ), + package_graph=package, + leak_graph=leak, + ): + await session.verify( + ("x86", "x64", "arm64"), corpus=corpus, run_nonce="verify-run", + fuzz_seconds=7, test_shards=5, warmup=2, iterations=3, + windows=4, tolerance_bytes=6, + ) + + self.assertEqual(len(session.runtime.executed), 2) + final = session.runtime.executed[-1] + self.assertTrue({"analysis", "source", "cppcheck", "python", "coverage", "sanitizer", "fuzz", + "package", "leak"}.issubset(final.targets)) + self.assertEqual( + {result.id for current in final.nodes for result in current.results}, + {result.id for result in representative_results.values()}, + ) + for result in representative_results.values(): + self.assertEqual(final.result(result.id)[1], result) + self.assertEqual(native.call_args.kwargs["configurations"], ("Debug", "Release")) + self.assertEqual(native.call_args.kwargs["runnable_architectures"], ("x86", "x64")) + self.assertTrue(native.call_args.kwargs["include_leak_probe"]) + self.assertEqual(coverage.call_args.kwargs["corpus"], corpus) + self.assertEqual(coverage.call_args.kwargs["run_nonce"], "verify-run") + self.assertEqual(sanitizer.call_args.kwargs["selections"], ( + ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64"), + )) + self.assertEqual(fuzz.call_args.kwargs["seconds"], 7) + self.assertEqual(leak.call_args.kwargs["tolerance_bytes"], 6) + self.assertEqual( + package.call_args.kwargs["smoke_architectures"], + ("x86", "x64"), + ) + source_tools.assert_called_once_with(self.toolchain) + common_source.assert_called_once() + architecture_source.assert_called_once() + asan.assert_called_once_with(self.toolchain, ("x86", "x64")) + resolve_ubsan.assert_called_once_with(self.toolchain) + + async def test_verify_source_rejects_duplicate_result_ids_across_families(self) -> None: + session = self.session() + duplicate = Result( + "reports/sarif/source/analysis.sarif", + "report", "application/sarif+json", "analysis.sarif", + ) + common = Graph( + (node("source", results=(duplicate,)),), ("source",), {"slot": 4} + ) + python = Graph( + (node("python", results=(duplicate,)),), ("python",), {"slot": 4} + ) + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + self.assertRaisesRegex( + GraphError, + "duplicate result id: reports/sarif/source/analysis[.]sarif", + ), + ): + await session.verify_source() + + self.assertEqual(session.runtime.executed, []) + + async def test_verify_source_runs_only_common_source_and_python_coverage(self) -> None: + session = self.session() + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools") as tools, + mock.patch.object(driver, "common_source_checks", return_value=common) as source, + mock.patch.object(driver, "python_coverage_graph", return_value=python) as coverage, + mock.patch.object(driver, "export_results") as export, + ): + await session.verify_source(export_dir=self.repository / "evidence") + + self.assertEqual(len(session.runtime.executed), 1) + self.assertEqual(set(session.runtime.executed[0].targets), {"source", "python"}) + tools.assert_called_once_with(self.toolchain) + source.assert_called_once_with(self.repository, "tools", jobs=4) + coverage.assert_called_once_with(self.repository) + export.assert_called_once_with( + session.runtime.executed[0], session.runtime.store, + self.repository / "evidence", "verify-source", "success", failures=(), + cache=session.runtime.cache_report(session.runtime.executed[0]), + ) + + async def test_enabled_cas_sweep_runs_before_successful_result_export(self) -> None: + session = self.session(prune_cas=True) + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + events: list[str] = [] + + def record(event: str) -> None: + self.assertTrue(session.runtime.session_active) + events.append(event) + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object( + driver, "export_results", side_effect=lambda *_args, **_kwargs: record("export") + ), + mock.patch.object( + driver, "sweep_cas", side_effect=lambda *_args, **_kwargs: record("sweep") + ) as sweep, + ): + await session.verify_source(export_dir=self.repository / "evidence") + + self.assertEqual(events, ["sweep", "export"]) + self.assertFalse(session.runtime.session_active) + graph = session.runtime.executed[0] + sweep.assert_called_once_with( + self.repository, session.runtime.live_uids(graph), owned_run_id="run-1" + ) + + async def test_cas_sweep_failure_exports_failed_status_before_reraising(self) -> None: + session = self.session(prune_cas=True) + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + maintenance = RuntimeError("prune failed") + events: list[str] = [] + + def fail_sweep(*_args, **_kwargs) -> None: + events.append("sweep") + raise maintenance + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object(driver, "sweep_cas", side_effect=fail_sweep), + mock.patch.object( + driver, "export_results", + side_effect=lambda *_args, **_kwargs: events.append("export"), + ) as export, + self.assertRaises(RuntimeError) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception, maintenance) + self.assertEqual(events, ["sweep", "export"]) + self.assertEqual(export.call_args.args[4], "failed") + self.assertEqual(export.call_args.kwargs["failures"], ()) + + async def test_cas_sweep_failure_without_export_reraises_inside_session(self) -> None: + session = self.session(prune_cas=True) + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + maintenance = RuntimeError("prune failed") + + def fail_sweep(*_args, **_kwargs) -> None: + self.assertTrue(session.runtime.session_active) + raise maintenance + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object(driver, "sweep_cas", side_effect=fail_sweep), + self.assertRaises(RuntimeError) as raised, + ): + await session.verify_source() + + self.assertIs(raised.exception, maintenance) + self.assertFalse(session.runtime.session_active) + + async def test_cas_sweep_and_failed_export_preserve_both_errors(self) -> None: + session = self.session(prune_cas=True) + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + maintenance = RuntimeError("prune failed") + export_failure = RuntimeError("export failed") + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object(driver, "sweep_cas", side_effect=maintenance), + mock.patch.object(driver, "export_results", side_effect=export_failure), + self.assertRaises(ExceptionGroup) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception.exceptions[0], maintenance) + self.assertIs(raised.exception.exceptions[1], export_failure) + self.assertFalse(session.runtime.session_active) + + async def test_failed_public_command_exports_then_sweeps_before_reraising(self) -> None: + session = self.session(prune_cas=True) + common = Graph((node("source"),), ("source",), {"slot": 4}) + python = Graph((node("python"),), ("python",), {"slot": 4}) + failure = ExceptionGroup("failed", (RuntimeError("expected"),)) + failure.failed_nodes = ("source",) + live_uids = (common.nodes[0].uid,) + events: list[str] = [] + + async def fail() -> None: + raise failure + + def live(_graph: Graph) -> tuple[str, ...]: + events.append("live") + return live_uids + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=common), + mock.patch.object(driver, "python_coverage_graph", return_value=python), + mock.patch.object(session.runtime, "run", side_effect=fail), + mock.patch.object( + driver, "export_results", + side_effect=lambda *_args, **_kwargs: events.append("export"), + ) as export, + mock.patch.object(session.runtime, "live_uids", side_effect=live), + mock.patch.object( + driver, "sweep_cas", + side_effect=lambda *_args, **_kwargs: events.append("sweep"), + ) as sweep, + self.assertRaises(ExceptionGroup) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception, failure) + self.assertEqual(events, ["export", "live", "sweep"]) + graph = session.runtime.executed[0] + export.assert_called_once_with( + graph, session.runtime.store, self.repository / "failed-evidence", + "verify-source", "failed", failures=("source",), + cache=session.runtime.cache_report(graph), + ) + sweep.assert_called_once_with( + self.repository, live_uids, owned_run_id="run-1" + ) + + async def test_export_failure_preserves_the_original_execution_failure(self) -> None: + session = self.session() + graph = Graph((node("source"),), ("source",), {"slot": 4}) + execution = ExceptionGroup("failed", (RuntimeError("execution"),)) + execution.failed_nodes = ("source",) + + async def fail() -> None: + raise execution + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=graph), + mock.patch.object( + driver, "python_coverage_graph", + return_value=Graph((node("python"),), ("python",), {"slot": 4}), + ), + mock.patch.object(session.runtime, "run", side_effect=fail), + mock.patch.object(driver, "export_results", side_effect=RuntimeError("export")), + self.assertRaises(ExceptionGroup) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception.exceptions[0], execution) + self.assertRegex(str(raised.exception.exceptions[1]), "export") + + async def test_failed_command_and_cas_sweep_failure_preserve_both_errors(self) -> None: + session = self.session(prune_cas=True) + graph = Graph((node("source"),), ("source",), {"slot": 4}) + execution = ExceptionGroup("failed", (RuntimeError("execution"),)) + execution.failed_nodes = ("source",) + maintenance = RuntimeError("prune") + + async def fail() -> None: + raise execution + + with ( + mock.patch.object(driver, "discover_source_tools", return_value="tools"), + mock.patch.object(driver, "common_source_checks", return_value=graph), + mock.patch.object( + driver, "python_coverage_graph", + return_value=Graph((node("python"),), ("python",), {"slot": 4}), + ), + mock.patch.object(session.runtime, "run", side_effect=fail), + mock.patch.object(driver, "export_results"), + mock.patch.object(driver, "sweep_cas", side_effect=maintenance), + self.assertRaises(ExceptionGroup) as raised, + ): + await session.verify_source(export_dir=self.repository / "failed-evidence") + + self.assertIs(raised.exception.exceptions[0], execution) + self.assertIs(raised.exception.exceptions[1], maintenance) + self.assertIn("cache pruning failed", str(raised.exception)) + + async def test_failure_before_graph_composition_does_not_publish_empty_evidence(self) -> None: + session = self.session() + with ( + mock.patch.object( + driver, "discover_source_tools", side_effect=RuntimeError("discovery") + ), + mock.patch.object(driver, "export_results") as export, + self.assertRaisesRegex(RuntimeError, "discovery"), + ): + await session.verify_source(export_dir=self.repository / "evidence") + export.assert_not_called() + + async def test_failure_after_discovery_export_does_not_prune_from_partial_graph(self) -> None: + session = self.session(prune_cas=True) + discovery = discovery_graph(("x64",)) + composition = RuntimeError("manifest composition failed") + + async def operation() -> tuple[Path, ...]: + await session._staged( + discovery, + mock.Mock(side_effect=composition), + ) + self.fail("composition failure must propagate") + + with ( + mock.patch.object(driver, "load_dependency_manifests", return_value={}), + mock.patch.object(driver, "export_results") as export, + mock.patch.object(driver, "sweep_cas") as sweep, + self.assertRaises(RuntimeError) as raised, + ): + await session._public( + "verify-arch", self.repository / "failed-evidence", operation + ) + + self.assertIs(raised.exception, composition) + self.assertEqual(session.runtime.executed, [discovery]) + self.assertEqual(export.call_args.args[4], "failed") + sweep.assert_not_called() + + async def test_verify_arch_omits_common_and_nonrunnable_specialists(self) -> None: + session = self.session() + route = SimpleNamespace( + runnable=(), coverage=(), asan=(), ubsan=(), run_x64_specialists=False, + ) + discovery = discovery_graph(("arm64",)) + + def family(name: str, upstream: Graph | None = None) -> Graph: + pools = dict(upstream.pools) if upstream else {"slot": 4} + nodes = upstream.nodes if upstream else () + marker = node(name) + return Graph(nodes + (marker,), (marker.name,), pools) + + unused = { + name: mock.Mock() for name in ( + "resolve_umdh", "resolve_asan_runtimes", "resolve_ubsan_runtime", + "coverage_dependency_discovery_slice", "sanitizer_dependency_discovery_slice", + "fuzz_dependency_discovery_slice", "coverage_graph", "sanitizer_graph", + "fuzz_graph", "leak_graph", "common_source_checks", "python_coverage_graph", + ) + } + native = mock.Mock(side_effect=native_result) + with mock.patch.multiple( + driver, + verify_route=mock.Mock(return_value=route), + discover_source_tools=mock.Mock(return_value="source-tools"), + resolve_dumpbin=mock.Mock(return_value=self.tool("dumpbin")), + resolve_binskim=mock.Mock(return_value=self.tool("binskim")), + analysis_discovery_slice=mock.Mock(return_value=discovery), + native_dependency_discovery_slice=mock.Mock(return_value=discovery), + load_dependency_manifests=mock.Mock(return_value={}), + native_graph=native, + analysis_slice=mock.Mock(return_value=family("analysis")), + architecture_source_checks=mock.Mock(return_value=family("cppcheck")), + audit_graph=mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("audit", upstream) + ), + package_graph=mock.Mock( + side_effect=lambda _repo, upstream, *_args, **_kwargs: family("package", upstream) + ), + **unused, + ): + await session.verify_arch(("arm64",), run_nonce="verify-run") + + self.assertEqual(len(session.runtime.executed), 2) + self.assertTrue({"analysis", "cppcheck", "package"}.issubset( + session.runtime.executed[-1].targets + )) + self.assertFalse(native.call_args.kwargs["include_leak_probe"]) + for current in unused.values(): + current.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_execute.py b/build/tests/test_execute.py new file mode 100644 index 0000000..777ade6 --- /dev/null +++ b/build/tests/test_execute.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +import hashlib +from pathlib import Path +import sys +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.execute import ExecutionError, Executor # noqa: E402 +from core.graph import Command, Graph, GraphError, Node # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = (), pool: str = "cpu") -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + pool, + Command(("synthetic", name)), + inputs, + ) + + +class ExecutorTests(unittest.IsolatedAsyncioTestCase): + async def test_explicit_target_subset_returns_none_and_skips_other_targets(self) -> None: + graph = Graph( + (node("selected"), node("other")), + ("selected", "other"), + {"cpu": 2}, + ) + complete: set[str] = set() + calls: list[str] = [] + + async def runner(current: Node) -> None: + calls.append(current.name) + + result = await Executor( + graph, + is_complete=lambda current: current.name in complete, + runner=runner, + publish=lambda current: complete.add(current.name), + ).run(("selected",)) + + self.assertIsNone(result) + self.assertEqual(calls, ["selected"]) + + with self.assertRaisesRegex(GraphError, "target.*empty"): + await Executor( + graph, + is_complete=lambda _current: False, + runner=runner, + publish=lambda _current: None, + ).run(()) + + async def test_demand_traversal_deduplicates_a_shared_dependency(self) -> None: + graph = Graph( + ( + node("shared"), + node("left", inputs=("shared",)), + node("right", inputs=("shared",)), + node("unreachable"), + ), + ("left", "right"), + {"cpu": 4}, + ) + complete: set[str] = set() + calls: list[str] = [] + + async def runner(current: Node) -> None: + calls.append(current.name) + await asyncio.sleep(0.01) + + await Executor( + graph, + is_complete=lambda current: current.name in complete, + runner=runner, + publish=lambda current: complete.add(current.name), + ).run() + + self.assertEqual(calls.count("shared"), 1) + self.assertCountEqual(calls, ["shared", "left", "right"]) + self.assertNotIn("unreachable", calls) + + async def test_named_pool_limits_concurrency(self) -> None: + graph = Graph( + (node("one"), node("two"), node("three")), + ("one", "two", "three"), + {"cpu": 2}, + ) + complete: set[str] = set() + active = 0 + maximum = 0 + + async def runner(_current: Node) -> None: + nonlocal active, maximum + active += 1 + maximum = max(maximum, active) + try: + await asyncio.sleep(0.02) + finally: + active -= 1 + + await Executor( + graph, + is_complete=lambda current: current.name in complete, + runner=runner, + publish=lambda current: complete.add(current.name), + ).run() + + self.assertEqual(maximum, 2) + + async def test_shared_slot_pool_limits_total_cross_family_concurrency(self) -> None: + graph = Graph( + (node("compile", pool="slot"),) + tuple( + node(f"audit-{index}", pool="audit") for index in range(4) + ), + ("compile",) + tuple(f"audit-{index}" for index in range(4)), + {"slot": 2, "audit": 4}, + ) + complete: set[str] = set() + active = maximum = 0 + + async def runner(_current: Node) -> None: + nonlocal active, maximum + active += 1 + maximum = max(maximum, active) + try: + await asyncio.sleep(0.02) + finally: + active -= 1 + + await Executor( + graph, + is_complete=lambda current: current.name in complete, + runner=runner, + publish=lambda current: complete.add(current.name), + ).run() + + self.assertEqual(maximum, 2) + + async def test_warm_target_cache_skips_dependencies(self) -> None: + graph = Graph( + (node("dependency"), node("target", inputs=("dependency",))), + ("target",), + {"cpu": 2}, + ) + + async def runner(_current: Node) -> None: + raise AssertionError("warm target and its dependencies must not run") + + await Executor( + graph, + is_complete=lambda current: current.name == "target", + runner=runner, + publish=lambda _current: None, + ).run() + + async def test_external_lock_rechecks_cache_before_dependencies(self) -> None: + graph = Graph( + (node("dependency"), node("target", inputs=("dependency",))), + ("target",), + {"cpu": 1}, + ) + complete = False + events: list[str] = [] + + @asynccontextmanager + async def lock(current: Node): + nonlocal complete + events.append(f"enter:{current.name}") + complete = current.name == "target" + try: + yield + finally: + events.append(f"leave:{current.name}") + + async def runner(_current: Node) -> None: + raise AssertionError("cache was completed while waiting for the lock") + + await Executor( + graph, + is_complete=lambda current: complete and current.name == "target", + runner=runner, + publish=lambda _current: None, + acquire_lock=lock, + ).run() + + self.assertEqual(events, ["enter:target", "leave:target"]) + + async def test_runner_must_publish_completion(self) -> None: + graph = Graph((node("broken"),), ("broken",), {"cpu": 1}) + + async def runner(_current: Node) -> None: + pass + + with self.assertRaises(ExceptionGroup) as raised: + await Executor( + graph, + is_complete=lambda _current: False, + runner=runner, + publish=lambda _current: None, + ).run() + self.assertIn("broken", repr(raised.exception.subgroup(ExecutionError))) + + async def test_failures_block_descendants_while_independent_successes_publish(self) -> None: + graph = Graph( + ( + node("first-failure"), + node("blocked", inputs=("first-failure",)), + node("blocked-descendant", inputs=("blocked",)), + node("second-failure"), + node("independent"), + ), + ("blocked-descendant", "blocked", "first-failure", "second-failure", "independent"), + {"cpu": 3}, + ) + independent_started = asyncio.Event() + complete: set[str] = set() + calls: list[str] = [] + + async def runner(current: Node) -> None: + calls.append(current.name) + if current.name == "independent": + independent_started.set() + await asyncio.sleep(0.02) + return + await independent_started.wait() + if current.name == "first-failure": + raise ValueError("first expected failure") + if current.name == "second-failure": + raise RuntimeError("second expected failure") + + with self.assertRaises(ExceptionGroup) as raised: + await Executor( + graph, + is_complete=lambda current: current.name in complete, + runner=runner, + publish=lambda current: complete.add(current.name), + ).run() + + self.assertEqual(complete, {"independent"}) + self.assertCountEqual(calls, ["first-failure", "second-failure", "independent"]) + self.assertNotIn("blocked", calls) + self.assertNotIn("blocked-descendant", calls) + self.assertEqual(len(raised.exception.exceptions), 2) + self.assertEqual( + raised.exception.failed_nodes, + ("first-failure", "second-failure"), + ) + self.assertIn("first-failure", str(raised.exception)) + self.assertIn("second-failure", str(raised.exception)) + self.assertIn("first expected failure", repr(raised.exception.subgroup(ValueError))) + self.assertIn("second expected failure", repr(raised.exception.subgroup(RuntimeError))) + + async def test_completion_predicate_is_synchronous_and_returns_bool(self) -> None: + graph = Graph((node("invalid"),), ("invalid",), {"cpu": 1}) + + async def runner(_current: Node) -> None: + pass + + with self.assertRaises(ExceptionGroup) as raised: + await Executor( + graph, + is_complete=lambda _current: "yes", # type: ignore[arg-type,return-value] + runner=runner, + publish=lambda _current: None, + ).run() + self.assertIn("did not return bool", repr(raised.exception.subgroup(ExecutionError))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_fuzz_graph.py b/build/tests/test_fuzz_graph.py new file mode 100644 index 0000000..901989a --- /dev/null +++ b/build/tests/test_fuzz_graph.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from core.paths import BuildPaths # noqa: E402 +from graphs.fuzz import ( # noqa: E402 + FuzzCorpusArtifact, + fuzz_corpus_artifacts, + fuzz_dependency_discovery_slice, + fuzz_graph, +) + + +TARGETS = ("pickle", "renpy", "rpgmaker", "zanzarah") + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + llvm_dir: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +class FuzzGraphTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + project = """ + + + +""" + files = { + "build/ObserverProjectConfigurations.props": "\n", + "build/ObserverConfiguration.props": "\n", + "build/ObserverProject.props": "\n", + "build/ObserverFuzz.props": "\n", + "vcpkg.json": '{"dependencies":["zlib"]}\n', + "build/vcpkg/triplets/observer-x64-windows-static-asan.cmake": ( + "set(VCPKG_LIBRARY_LINKAGE static)\n" + ), + } + for target in TARGETS: + files[f"build/projects/fuzz-{target}.vcxproj"] = project.format(target=target) + files[f"src/fuzz/{target}.cpp"] = f'#include "{target}.h"\n' + files[f"src/fuzz/{target}.h"] = "#pragma once\n" + files[f"src/fuzz/corpus/{target}/seed.hex"] = "00 7f ff\n" + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def toolchain(self, root: Path) -> FakeToolchain: + tools = root / "fake tools" + llvm = tools / "llvm" + llvm_bin = llvm / "bin" + runtime = llvm / "lib/clang/19/lib/windows" + vcpkg = tools / "vcpkg" + llvm_bin.mkdir(parents=True) + runtime.mkdir(parents=True) + vcpkg.mkdir() + (vcpkg / "vcpkg.exe").touch() + msbuild, pwsh = tools / "MSBuild.exe", tools / "pwsh.exe" + msbuild.touch() + pwsh.touch() + for name in ("clang-cl.exe", "clang-scan-deps.exe"): + (llvm_bin / name).touch() + return FakeToolchain( + msbuild, + pwsh, + vcpkg, + llvm, + (("PATH", str(tools)), ("VCPKG_ROOT", "stale")), + {"msbuild": "17.14", "llvm": "19.1.5", "vcpkg": "2026.07"}, + ) + + def graph(self, repository: Path, toolchain: FakeToolchain, **kwargs): + jobs = kwargs.get("jobs", 2) + targets = kwargs.get("targets", TARGETS) + discovery = fuzz_dependency_discovery_slice( + repository, toolchain, jobs=jobs, targets=targets + ) + manifests = {} + for target, name in zip(targets, discovery.targets, strict=True): + source = repository / f"src/fuzz/{target}.cpp" + header = repository / f"src/fuzz/{target}.h" + manifests[name] = json.dumps( + { + "Data": { + "Source": str(source.resolve()), + "Includes": [str(header.resolve())], + } + } + ).encode() + return discovery, fuzz_graph( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + **kwargs, + ) + + def test_discovery_names_are_confined_to_the_fuzz_configuration_namespace(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery = fuzz_dependency_discovery_slice( + repository, self.toolchain(root), targets=("pickle",) + ) + + self.assertTrue(discovery.targets[0].startswith( + "discover-dependencies-x64-fuzz-fuzz-pickle-" + )) + scanned = discovery.node(discovery.targets[0]) + self.assertEqual(len(scanned.inputs), 1) + self.assertTrue(scanned.inputs[0].startswith( + "capture-clang-command-x64-fuzz-fuzz-pickle-" + )) + + def test_target_subset_is_exact_and_preserves_existing_node_uids(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _full_discovery, full = self.graph( + repository, toolchain, run_nonce="same" + ) + subset_discovery, subset = self.graph( + repository, toolchain, run_nonce="same", + targets=("pickle", "zanzarah"), + ) + + self.assertEqual( + subset.targets, ("fuzz-x64-pickle", "fuzz-x64-zanzarah") + ) + self.assertEqual( + fuzz_corpus_artifacts(subset), + tuple( + FuzzCorpusArtifact(target, subset.node(f"run-fuzz-x64-{target}").uid) + for target in ("pickle", "zanzarah") + ), + ) + self.assertTrue(all("renpy" not in node.name and "rpgmaker" not in node.name + for node in subset.nodes)) + self.assertEqual(len(subset_discovery.targets), 2) + for current in subset.nodes: + self.assertEqual(current.uid, full.node(current.name).uid, current.name) + + def test_targets_must_be_a_unique_nonempty_supported_subset(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + for targets, message in ( + ((), "must not be empty"), + (("pickle", "pickle"), "duplicate fuzz target: pickle"), + (("unknown",), "unsupported fuzz target: unknown"), + ): + with self.subTest(targets=targets), self.assertRaisesRegex(ValueError, message): + fuzz_dependency_discovery_slice( + repository, toolchain, targets=targets + ) + + def test_four_targets_are_independent_build_replay_and_bounded_run_branches(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.graph( + repository, self.toolchain(root), run_nonce="test-run", seconds=37, + jobs=3, fuzz_jobs=2, + ) + + self.assertEqual(graph.pools, {"build": 3, "fuzz": 2, "restore": 1, "slot": 3}) + self.assertEqual(graph.targets, tuple(f"fuzz-x64-{target}" for target in TARGETS)) + self.assertEqual( + fuzz_corpus_artifacts(graph), + tuple( + FuzzCorpusArtifact(target, graph.node(f"run-fuzz-x64-{target}").uid) + for target in TARGETS + ), + ) + self.assertEqual(len(graph.nodes), 1 + len(TARGETS) * 6) + restore = graph.node("restore-vcpkg-asan-x64") + self.assertEqual(restore.pool, "restore") + self.assertIn("observer-x64-windows-static-asan", restore.command.stdin.decode()) + self.assertEqual(dict(restore.command.env)["VCPKG_ROOT"], str(root / "fake tools/vcpkg")) + + for target in TARGETS: + build = graph.node(f"build-fuzz-x64-{target}") + replay = graph.node(f"replay-fuzz-x64-{target}") + run = graph.node(f"run-fuzz-x64-{target}") + gate = graph.node(f"fuzz-x64-{target}") + expected_discovery = next( + name for name in discovery.targets if f"-fuzz-{target}-" in name + ) + self.assertEqual(build.inputs, (expected_discovery,)) + self.assertEqual(replay.inputs, (build.name,)) + self.assertEqual(run.inputs, (replay.name,)) + self.assertEqual(gate.inputs, (run.name,)) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in run.results), + ( + (f"reports/fuzz/x64/{target}/status.txt", "fuzz", "text/plain", "status.txt"), + (f"reports/fuzz/x64/{target}/corpus", "corpus", "application/octet-stream", "corpus"), + (f"reports/fuzz/x64/{target}/artifacts", "evidence", "application/octet-stream", "artifacts"), + ), + ) + self.assertTrue(all(not item.results for item in (build, replay, gate))) + self.assertEqual( + (build.pool, replay.pool, run.pool, gate.pool), + ("build", "fuzz", "fuzz", "fuzz"), + ) + self.assertFalse(any(other in " ".join(run.inputs) for other in TARGETS if other != target)) + + def test_build_and_run_recipes_preserve_the_current_fuzzer_contract(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _discovery, graph = self.graph( + repository, toolchain, run_nonce="test-run", seconds=37 + ) + + build = graph.node("build-fuzz-x64-pickle") + replay = graph.node("replay-fuzz-x64-pickle") + run = graph.node("run-fuzz-x64-pickle") + gate = graph.node("fuzz-x64-pickle") + + build_script = build.command.stdin.decode("utf-8") + self.assertIn(str(repository / "build/projects/fuzz-pickle.vcxproj"), build_script) + self.assertIn("'/t:Build'", build_script) + self.assertIn("'/p:Configuration=Fuzz'", build_script) + self.assertIn("'/p:Platform=x64'", build_script) + self.assertRegex(build_script, r"'/p:VcpkgInstalledDir=.*\\'") + self.assertIn("'/p:LLVMInstallDir=", build_script) + self.assertIn("'/p:LLVMRuntimeDir=", build_script) + self.assertIn("fuzz-pickle.exe", build_script) + + replay_script = replay.command.stdin.decode("utf-8") + run_script = run.command.stdin.decode("utf-8") + for script in (replay_script, run_script): + self.assertIn("$buildDir 'corpus'", script) + self.assertIn("FromHexString", script) + self.assertIn("-max_len=262144", script) + self.assertIn("-rss_limit_mb=1024", script) + self.assertIn("-timeout=10", script) + self.assertNotIn("-max_total_time", replay_script) + self.assertIn("Get-ChildItem -LiteralPath $corpus", replay_script) + self.assertIn("-max_total_time=37", run_script) + self.assertIn("-use_value_profile=1", run_script) + self.assertIn("$outDir 'corpus'", run_script) + self.assertIn("status.txt", run_script) + self.assertIn("$PSNativeCommandUseErrorActionPreference = $false", run_script) + self.assertNotIn("Invoke-Checked $fuzzer", run_script) + gate_script = gate.command.stdin.decode("utf-8") + self.assertIn("status.txt", gate_script) + self.assertIn("Fuzzer exited with code", gate_script) + runtime = str(toolchain.llvm_dir / "lib/clang/19/lib/windows") + self.assertTrue(dict(run.command.env)["PATH"].startswith(runtime)) + self.assertEqual( + dict(run.command.env)["ASAN_OPTIONS"], + "halt_on_error=1:alloc_dealloc_mismatch=1", + ) + parser = ( + "$tokens=$null;$errors=$null;" + "[Management.Automation.Language.Parser]::ParseInput(" + "[Console]::In.ReadToEnd(),[ref]$tokens,[ref]$errors)|Out-Null;" + "if($errors.Count){$errors|ForEach-Object ToString;exit 1}" + ) + parsed = subprocess.run( + [shutil.which("pwsh"), "-NoLogo", "-NoProfile", "-Command", parser], + input="\n".join((build_script, replay_script, run_script, gate_script)), + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(0, parsed.returncode, parsed.stderr or parsed.stdout) + + def test_one_seed_change_invalidates_only_its_replay_and_run_branch(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _discovery, before = self.graph(repository, toolchain, run_nonce="test-run") + (repository / "src/fuzz/corpus/pickle/seed.hex").write_text("01\n", encoding="utf-8") + _discovery, after = self.graph(repository, toolchain, run_nonce="test-run") + + changed = { + "replay-fuzz-x64-pickle", + "run-fuzz-x64-pickle", + "fuzz-x64-pickle", + } + for node in before.nodes: + comparison = after.node(node.name) + if node.name in changed: + self.assertNotEqual(node.uid, comparison.uid, node.name) + else: + self.assertEqual(node.uid, comparison.uid, node.name) + + def test_actual_msbuild_contract_rejects_every_non_x64_architecture(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + for architecture in ("x86", "arm64"): + with self.subTest(architecture=architecture): + with self.assertRaisesRegex(ValueError, "x64-only"): + fuzz_graph( + repository, toolchain, run_nonce="test-run", + discovery=None, manifests={}, + architectures=(architecture,), + ) + + with self.assertRaisesRegex(ValueError, "run nonce"): + fuzz_graph(repository, toolchain, run_nonce="", discovery=None, manifests={}) + with self.assertRaisesRegex(ValueError, "dependency discovery is required"): + fuzz_graph( + repository, toolchain, run_nonce="test-run", + discovery=None, manifests={}, + ) + + def test_build_requires_one_compiler_manifest_per_project_tu(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + discovery = fuzz_dependency_discovery_slice(repository, toolchain) + + with self.assertRaisesRegex(ValueError, "missing dependency manifest"): + fuzz_graph( + repository, toolchain, discovery=discovery, manifests={}, + run_nonce="test-run", + ) + + def test_run_nonce_invalidates_only_the_four_bounded_runs(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _discovery, before = self.graph(repository, toolchain, run_nonce="run-one") + _discovery, after = self.graph(repository, toolchain, run_nonce="run-two") + + for node in before.nodes: + comparison = after.node(node.name) + if node.name.startswith(("run-fuzz-x64-", "fuzz-x64-")): + self.assertNotEqual(node.uid, comparison.uid, node.name) + else: + self.assertEqual(node.uid, comparison.uid, node.name) + + def test_prior_published_corpus_seeds_and_signs_only_its_next_run(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + _discovery, before = self.graph(repository, toolchain, run_nonce="same") + producer = before.node("run-fuzz-x64-pickle") + cas = BuildPaths(repository).cas(producer.uid) + (cas.output / "corpus").mkdir(parents=True) + (cas.output / "corpus/evolved").write_bytes(b"first") + cas.log.write_text("green\n", encoding="utf-8") + cas.touch.touch() + artifact = FuzzCorpusArtifact("pickle", producer.uid) + + _discovery, seeded = self.graph( + repository, toolchain, run_nonce="same", prior_corpora=(artifact,) + ) + (cas.output / "corpus/evolved").write_bytes(b"second") + _discovery, content_changed = self.graph( + repository, toolchain, run_nonce="same", prior_corpora=(artifact,) + ) + + changed = {"run-fuzz-x64-pickle", "fuzz-x64-pickle"} + for node in before.nodes: + comparison = seeded.node(node.name) + if node.name in changed: + self.assertNotEqual(node.uid, comparison.uid, node.name) + else: + self.assertEqual(node.uid, comparison.uid, node.name) + self.assertNotEqual( + seeded.node("run-fuzz-x64-pickle").uid, + content_changed.node("run-fuzz-x64-pickle").uid, + ) + script = seeded.node("run-fuzz-x64-pickle").command.stdin.decode() + self.assertIn(str(cas.output / "corpus"), script) + self.assertIn("Copy-Item", script) + + def test_prior_corpus_must_be_unique_and_complete_canonical_cas(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + missing = FuzzCorpusArtifact("pickle", "0" * 32) + with self.assertRaisesRegex(FileNotFoundError, "not published"): + self.graph( + repository, toolchain, run_nonce="run", prior_corpora=(missing,) + ) + with self.assertRaisesRegex(ValueError, "duplicate prior corpus"): + self.graph( + repository, + toolchain, + run_nonce="run", + prior_corpora=(missing, missing), + ) + with self.assertRaisesRegex(ValueError, "unsupported fuzz corpus target"): + FuzzCorpusArtifact("unknown", "0" * 32) + for invalid_uid in (42, "invalid"): + with self.subTest(invalid_uid=invalid_uid), self.assertRaisesRegex( + ValueError, "canonical MD5" + ): + FuzzCorpusArtifact("pickle", invalid_uid) + + def test_prior_corpus_rejects_empty_or_nonfile_content(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + paths = BuildPaths(repository) + empty = paths.cas("1" * 32) + (empty.output / "corpus").mkdir(parents=True) + empty.log.touch() + empty.touch.touch() + with self.assertRaisesRegex(ValueError, "corpus is empty"): + self.graph( + repository, + toolchain, + run_nonce="run", + prior_corpora=(FuzzCorpusArtifact("pickle", "1" * 32),), + ) + + nonfile = paths.cas("2" * 32) + (nonfile.output / "corpus/directory").mkdir(parents=True) + nonfile.log.touch() + nonfile.touch.touch() + with self.assertRaisesRegex(ValueError, "contains a non-file"): + self.graph( + repository, + toolchain, + run_nonce="run", + prior_corpora=(FuzzCorpusArtifact("pickle", "2" * 32),), + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_graph.py b/build/tests/test_graph.py new file mode 100644 index 0000000..6a9fe86 --- /dev/null +++ b/build/tests/test_graph.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +import sys +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Graph, GraphError, Node, Result, merge_graphs # noqa: E402 + + +def node( + name: str, + *, + inputs: tuple[str, ...] = (), + pool: str = "cpu", + results: tuple[Result, ...] = (), +) -> Node: + return Node( + name=name, + uid=hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + pool=pool, + command=Command(("tool", name)), + inputs=inputs, + results=results, + ) + + +class GraphTests(unittest.TestCase): + def test_results_are_typed_and_addressable_without_scanning_directories(self) -> None: + report = Result( + "analysis/renpy/x64/sarif", + "report", + "application/sarif+json", + "analysis/renpy-x64.sarif", + ) + producer = node("analyze-renpy-x64", results=(report,)) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + + self.assertEqual(producer.results, (report,)) + self.assertEqual(graph.result(report.id), (producer, report)) + with self.assertRaisesRegex(GraphError, "unknown result"): + graph.result("analysis/missing") + + def test_result_contract_rejects_ambiguous_identifiers_and_paths(self) -> None: + invalid = ( + (42, "report", "application/json", "report.json"), + ("Uppercase", "report", "application/json", "report.json"), + ("report", 42, "application/json", "report.json"), + ("report", "Report", "application/json", "report.json"), + ("report", "report", 42, "report.json"), + ("report", "report", "not-a-media-type", "report.json"), + ("report", "report", "application/json", 42), + ("report", "report", "application/json", ""), + ("report", "report", "application/json", "bad\0path"), + ("report", "report", "application/json", "../report.json"), + ("report", "report", "application/json", "reports/./report.json"), + ("report", "report", "application/json", "reports//report.json"), + ("report", "report", "application/json", r"reports\report.json"), + ("report", "report", "application/json", "/report.json"), + ("report", "report", "application/json", "C:/report.json"), + ) + for values in invalid: + with self.subTest(values=values), self.assertRaises(GraphError): + Result(*values) + + report = Result("report", "report", "application/json", "report.json") + with self.assertRaisesRegex(GraphError, "duplicate result ids"): + node("producer", results=(report, report)) + with self.assertRaisesRegex(GraphError, "results must be Result"): + Node( + "producer", + "0" * 32, + "cpu", + Command(("tool",)), + results=(object(),), # type: ignore[arg-type] + ) + with self.assertRaisesRegex(GraphError, "duplicate result id.*report"): + Graph( + (node("first", results=(report,)), node("second", results=(report,))), + ("first", "second"), + {"cpu": 1}, + ) + + def test_graphs_merge_shared_exact_nodes_pools_and_targets(self) -> None: + shared = node("shared") + first = Graph( + (shared, node("first", inputs=(shared.name,))), + ("first",), + {"cpu": 2}, + ) + second = Graph( + (shared, node("second", inputs=(shared.name,), pool="io")), + ("shared", "second"), + {"cpu": 2, "io": 1}, + ) + + merged = merge_graphs(first, second) + + self.assertEqual(tuple(current.name for current in merged.nodes), ("shared", "first", "second")) + self.assertEqual(merged.targets, ("first", "shared", "second")) + self.assertEqual(dict(merged.pools), {"cpu": 2, "io": 1}) + + def test_graph_merge_rejects_missing_inputs_and_conflicting_shared_contracts(self) -> None: + with self.assertRaisesRegex(GraphError, "at least one graph"): + merge_graphs() + + first = Graph((node("shared"),), ("shared",), {"cpu": 1}) + conflicting_node = Node("shared", "f" * 32, "cpu", Command(("tool",))) + second = Graph((conflicting_node,), ("shared",), {"cpu": 1}) + with self.assertRaisesRegex(GraphError, "conflicting node definition.*shared"): + merge_graphs(first, second) + + third = Graph((node("other"),), ("other",), {"cpu": 2}) + with self.assertRaisesRegex(GraphError, "conflicting pool capacity.*cpu"): + merge_graphs(first, third) + + def test_edges_and_targets_are_direct_node_names(self) -> None: + producer = node("producer") + consumer = node("consumer", inputs=("producer",)) + graph = Graph((consumer, producer), ("consumer",), {"cpu": 2}) + + self.assertEqual(consumer.inputs, ("producer",)) + self.assertEqual(graph.node("producer"), producer) + self.assertEqual(graph.dependencies_of("consumer"), (producer,)) + + def test_unknown_dependencies_targets_and_duplicate_names_are_rejected(self) -> None: + with self.assertRaisesRegex(GraphError, "duplicate node name"): + Graph((node("same"), node("same")), ("same",), {"cpu": 1}) + with self.assertRaisesRegex(GraphError, "unknown dependency.*missing"): + Graph( + (node("consumer", inputs=("missing",)),), + ("consumer",), + {"cpu": 1}, + ) + with self.assertRaisesRegex(GraphError, "unknown target.*missing"): + Graph((node("only"),), ("missing",), {"cpu": 1}) + + def test_cycles_are_rejected_before_execution(self) -> None: + with self.assertRaisesRegex(GraphError, "cycle.*first.*second.*first"): + Graph( + ( + node("second", inputs=("first",)), + node("first", inputs=("second",)), + ), + ("first",), + {"cpu": 1}, + ) + + def test_each_node_selects_one_known_positive_pool(self) -> None: + with self.assertRaisesRegex(GraphError, "unknown pool.*missing"): + Graph((node("only", pool="missing"),), ("only",), {"cpu": 1}) + for invalid in (0, -1, True): + with self.subTest(invalid=invalid), self.assertRaisesRegex( + GraphError, "invalid pool capacity" + ): + Graph((node("only"),), ("only",), {"cpu": invalid}) # type: ignore[dict-item] + + def test_node_identity_and_command_are_safe_runtime_descriptors(self) -> None: + command = Command( + ("pwsh", "argument with spaces", "&literal", ""), + env=(("ZED", "last"), ("ALPHA", "first")), + cwd="work/a directory", + stdin=b"Write-Output 'literal'\r\n", + ) + current = Node("safe-name.1", "0" * 32, "cpu", command) + + self.assertEqual(current.command.env, (("ALPHA", "first"), ("ZED", "last"))) + self.assertEqual(current.command.stdin, b"Write-Output 'literal'\r\n") + self.assertEqual(current.command.argv[-1], "") + with self.assertRaisesRegex(GraphError, "uid.*lowercase MD5"): + Node("safe", "NOT-A-UID", "cpu", Command(("tool",))) + with self.assertRaisesRegex(GraphError, "node name.*128"): + node("a" * 129) + + def test_ambiguous_command_and_node_data_are_rejected(self) -> None: + invalid_commands = ( + lambda: Command(()), + lambda: Command(("bad\0argument",)), + lambda: Command(("tool",), env=(("incomplete",),)), # type: ignore[arg-type] + lambda: Command(("tool",), env=(("KEY", object()),)), # type: ignore[arg-type] + lambda: Command(("tool",), env=(("PATH", "1"), ("Path", "2"))), + lambda: Command(("tool",), cwd="bad\0cwd"), + lambda: Command(("tool",), stdin="not bytes"), # type: ignore[arg-type] + ) + for constructor in invalid_commands: + with self.subTest(constructor=constructor), self.assertRaises(GraphError): + constructor() + + with self.assertRaisesRegex(GraphError, "duplicate dependencies"): + Node("safe", "0" * 32, "cpu", Command(("tool",)), ("same", "same")) + with self.assertRaisesRegex(GraphError, "command must be a Command"): + Node("safe", "0" * 32, "cpu", object()) # type: ignore[arg-type] + + def test_graph_container_and_lookups_are_explicit(self) -> None: + only = node("only") + invalid_graphs = ( + lambda: Graph((), ("only",), {"cpu": 1}), + lambda: Graph((only,), (), {"cpu": 1}), + lambda: Graph((only,), ("only", "only"), {"cpu": 1}), + lambda: Graph((object(),), ("only",), {"cpu": 1}), # type: ignore[arg-type] + lambda: Graph((only,), ("only",), {"": 1}), + ) + for constructor in invalid_graphs: + with self.subTest(constructor=constructor), self.assertRaises(GraphError): + constructor() + + graph = Graph((only,), ("only",), {"cpu": 1}) + with self.assertRaisesRegex(GraphError, "unknown node"): + graph.node("missing") + with self.assertRaisesRegex(GraphError, "unknown node"): + graph.dependencies_of("missing") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_graph_main_coverage.py b/build/tests/test_graph_main_coverage.py new file mode 100644 index 0000000..0f8a3cc --- /dev/null +++ b/build/tests/test_graph_main_coverage.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from graphs import analysis, common, fuzz, native # noqa: E402 + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + llvm_dir: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +def write(root: Path, relative: str, content: str = "") -> Path: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +def toolchain(root: Path, *, runtime: bool = False) -> FakeToolchain: + tools = root / "tools" + vcpkg = tools / "vcpkg" + llvm = tools / "llvm" + vcpkg.mkdir(parents=True) + llvm.mkdir() + if runtime: + (llvm / "lib/clang/19/lib/windows").mkdir(parents=True) + for path in (tools / "MSBuild.exe", tools / "pwsh.exe", vcpkg / "vcpkg.exe"): + path.touch() + return FakeToolchain( + tools / "MSBuild.exe", + tools / "pwsh.exe", + vcpkg, + llvm, + (("PATH", str(tools)),), + {"msbuild": "17.14", "msvc": "14.44", "llvm": "19.1"}, + ) + + +def native_repository(root: Path) -> Path: + project = """ + + +""" + for relative in ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", + ): + write(root, relative, "\n") + write(root, "vcpkg.json", "{}\n") + write(root, "build/vcpkg/triplets/observer-x64-windows-static.cmake") + for name in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe"): + write(root, f"src/{name}.cpp", "int value;\n") + write(root, f"build/projects/{name}.vcxproj", project.format(name=name)) + return root + + +class AnalysisCoverageTests(unittest.TestCase): + def test_tests_project_declares_cross_directory_header_dependencies(self) -> None: + repository = BUILD_ROOT.parent + project = analysis.project_inventory( + repository, ("tests",), include_link_inputs=False + )[0] + observer = repository / "src/tests/framework/observer.cpp" + pickle = repository / "src/tests/unit/pickle.cpp" + headers = ( + repository / "src/api.h", + repository / "src/archive.h", + repository / "src/modules/extractor.h", + ) + content = json.dumps({ + "Data": { + "Source": str(observer.resolve()), + "Includes": [str(headers[0].resolve())], + } + }).encode() + + observer_files = analysis.dependency_inputs( + repository, repository / "out/cas/unused/out", + observer, content, project.headers, + ) + pickle_files = analysis.dependency_inputs( + repository, repository / "out/cas/unused/out", pickle, + json.dumps({ + "Data": { + "Source": str(pickle.resolve()), + "Includes": [str(path.resolve()) for path in headers[1:]], + } + }).encode(), + project.headers, + ) + + self.assertEqual(observer_files["src/api.h"], headers[0].read_bytes()) + self.assertEqual(pickle_files["src/archive.h"], headers[1].read_bytes()) + self.assertEqual( + pickle_files["src/modules/extractor.h"], headers[2].read_bytes() + ) + + def test_compiler_manifest_accepts_only_exact_relevant_dependency_bytes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + source = write(repository, "src/module/source.cpp", "int value;\n") + first_party = write(repository, "src/shared.h", "first-party\n") + package_root = repository / "out/cas/package/out" + package = write(package_root, "include/package.h", "package\n") + system = write(repository, "sdk/system.h", "system\n") + content = json.dumps( + { + "Data": { + "Source": str(source.resolve()), + "Includes": [ + str(first_party.resolve()), + str(first_party.resolve()), + str(package.resolve()), + str(system.resolve()), + ], + } + } + ).encode() + + files = analysis.dependency_inputs( + repository, package_root, source, content, (first_party,) + ) + + self.assertEqual( + set(files), + {"compiler/dependencies.json", "src/module/source.cpp", "src/shared.h", "vcpkg/include/package.h"}, + ) + + def test_invalid_or_mismatched_compiler_manifests_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + source = write(repository, "src/source.cpp", "int value;\n") + package = repository / "out/cas/package/out" + package.mkdir(parents=True) + invalid = ( + b"{", + b"{}", + json.dumps({"Data": {"Source": 1, "Includes": []}}).encode(), + json.dumps({"Data": {"Source": str(source), "Includes": {}}}).encode(), + json.dumps({"Data": {"Source": str(source), "Includes": [None]}}).encode(), + json.dumps({"Data": {"Source": str(source), "Includes": ["relative.h"]}}).encode(), + json.dumps({"Data": {"Source": str(source), "Includes": [str(repository / 'src/missing.h')]}}).encode(), + ) + for content in invalid: + with self.subTest(content=content), self.assertRaisesRegex(ValueError, "invalid MSVC"): + analysis.dependency_inputs(repository, package, source, content) + mismatch = json.dumps( + {"Data": {"Source": str(write(repository, "src/other.cpp")), "Includes": []}} + ).encode() + with self.assertRaisesRegex(ValueError, "source mismatch"): + analysis.dependency_inputs(repository, package, source, mismatch) + + def test_project_inventory_ignores_empty_items_and_rejects_external_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + write( + repository, + "build/projects/empty.vcxproj", + '' + "", + ) + self.assertEqual(analysis._projects(repository), ()) + + write( + repository, + "build/projects/empty.vcxproj", + '' + '', + ) + with self.assertRaisesRegex(ValueError, "unsupported ClCompile path"): + analysis._projects(repository) + + def test_fuzz_analysis_signs_fuzz_props_and_unknown_architecture_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = root / "repo" + for relative in ( + "build/ObserverProjectConfigurations.props", + "build/ObserverConfiguration.props", + "build/ObserverProject.props", + "build/ObserverFuzz.props", + ): + write(repository, relative, "\n") + write(repository, "build/ObserverNativeAnalysis.ruleset", "\n") + write(repository, ".clang-tidy", "Checks: bugprone-*\n") + write(repository, "vcpkg.json", "{}\n") + write(repository, "build/vcpkg/triplets/observer-x64-windows-static.cmake") + write(repository, "src/fuzz/pickle.cpp", "int value;\n") + write( + repository, + "build/projects/fuzz-pickle.vcxproj", + '' + '' + "", + ) + fake = toolchain(root) + + before_discovery = analysis.analysis_discovery_slice(repository, fake) + name = before_discovery.targets[0] + manifest = json.dumps( + {"Data": {"Source": str((repository / "src/fuzz/pickle.cpp").resolve()), "Includes": []}} + ).encode() + before = analysis.analysis_slice( + repository, fake, discovery=before_discovery, manifests={name: manifest} + ) + write(repository, "build/ObserverFuzz.props", "\n") + after_discovery = analysis.analysis_discovery_slice(repository, fake) + after = analysis.analysis_slice( + repository, fake, discovery=after_discovery, manifests={name: manifest} + ) + node_name = "analyze-msvc-x64-fuzz-pickle-fuzz.pickle" + + self.assertNotEqual(before.node(node_name).uid, after.node(node_name).uid) + with self.assertRaisesRegex(ValueError, "unsupported architecture: mips"): + analysis.analysis_discovery_slice(repository, fake, architectures=("mips",)) + with self.assertRaisesRegex(ValueError, "unsupported architecture: mips"): + analysis.analysis_slice( + repository, + fake, + discovery=after_discovery, + manifests={name: manifest}, + architectures=("mips",), + ) + with self.assertRaisesRegex(ValueError, "missing dependency manifest"): + analysis.analysis_slice( + repository, fake, discovery=after_discovery, manifests={} + ) + + +class CommonAndFuzzCoverageTests(unittest.TestCase): + def test_environment_without_vcpkg_root_preserves_path_and_extra_values(self) -> None: + fake = type("Toolchain", (), {"environment": (("Path", "base"),)})() + + environment = dict( + common.tool_environment( + fake, + prepend_path=Path("runtime"), + extra=(("MODE", "checked"),), + ) + ) + + self.assertEqual(environment["PATH"], f"runtime{common.os.pathsep}base") + self.assertEqual(environment["MODE"], "checked") + self.assertNotIn("VCPKG_ROOT", environment) + + def test_missing_sanitizer_runtime_is_reported(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fake = toolchain(root) + + with self.assertRaisesRegex(FileNotFoundError, "sanitizer runtimes"): + fuzz._runtime(fake) + + def test_fuzzer_project_rejects_unsigned_source_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + for relative in fuzz._COMMON: + write(repository, relative, "\n") + write( + repository, + "build/projects/fuzz-pickle.vcxproj", + '' + '' + "", + ) + + with self.assertRaisesRegex(ValueError, "unsupported ClCompile path"): + fuzz._project_files( + repository, "pickle", Path("unused"), object(), {} + ) + + def test_empty_seed_corpus_and_nonpositive_duration_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + (repository / "src/fuzz/corpus/pickle").mkdir(parents=True) + with self.assertRaisesRegex(FileNotFoundError, "no checked-in fuzzer seeds"): + fuzz._seed_files(repository, "pickle") + + with self.assertRaisesRegex(ValueError, "fuzz seconds must be positive"): + fuzz.fuzz_graph( + Path("unused"), object(), discovery=None, manifests={}, + run_nonce="run", seconds=0, + ) + + +class NativeCoverageTests(unittest.TestCase): + def test_invalid_job_capacity_and_nonrunnable_release_leak_probe_contract(self) -> None: + with self.assertRaisesRegex(ValueError, "jobs must be a positive integer"): + native.native_graph( + Path("unused"), object(), jobs=0, runnable_architectures=() + ) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = native_repository(root / "repo") + fake = toolchain(root) + discovery = native.native_dependency_discovery_slice( + repository, fake, configurations=("Release",), include_leak_probe=True + ) + projects = ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe") + manifests = { + name: json.dumps( + { + "Data": { + "Source": str((repository / f"src/{next(project for project in projects if name.endswith('-' + project))}.cpp").resolve()), + "Includes": [], + } + } + ).encode() + for name in discovery.targets + } + graph = native.native_graph( + repository, + fake, + discovery=discovery, + manifests=manifests, + architectures=("x64",), + configurations=("Release",), + runnable_architectures=(), + test_shards=1, + include_leak_probe=True, + ) + + self.assertIn("build-leak-probe-x64-release", graph.targets) + self.assertFalse(any(name.startswith("test-shard-") for name in graph.targets)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_host.py b/build/tests/test_host.py new file mode 100644 index 0000000..e45ac4a --- /dev/null +++ b/build/tests/test_host.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import unittest + +from core.host import ( + detect_host_architecture, + require_runnable, + runnable_architectures, + verify_route, +) + + +class HostTests(unittest.TestCase): + def test_common_machine_names_are_canonicalized(self) -> None: + for machine, expected in ( + ("AMD64", "x64"), + ("x86_64", "x64"), + ("ARM64", "arm64"), + ("aarch64", "arm64"), + ("x86", "x86"), + ("i686", "x86"), + ): + with self.subTest(machine=machine): + self.assertEqual(detect_host_architecture(machine), expected) + + def test_unknown_machine_is_rejected(self) -> None: + with self.assertRaisesRegex(RuntimeError, "unsupported Windows host architecture"): + detect_host_architecture("mips64") + + def test_runnable_matrix_matches_windows_emulation_contract(self) -> None: + requested = ("x86", "x64", "arm64") + self.assertEqual(runnable_architectures(requested, "x86"), ("x86",)) + self.assertEqual(runnable_architectures(requested, "x64"), ("x86", "x64")) + self.assertEqual(runnable_architectures(requested, "arm64"), requested) + + def test_invalid_requested_and_host_architectures_are_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported requested architecture"): + runnable_architectures(("sparc",), "x64") + with self.assertRaisesRegex(ValueError, "unsupported host architecture"): + runnable_architectures(("x64",), "sparc") + + def test_test_command_contract_rejects_nonrunnable_requests(self) -> None: + with self.assertRaisesRegex(RuntimeError, "cannot run arm64 tests on x64 host"): + require_runnable(("x86", "arm64"), "x64") + + self.assertEqual(require_runnable(("x86", "x64"), "x64"), ("x86", "x64")) + + def test_verify_route_selects_host_capable_specialists(self) -> None: + route = verify_route(("x86", "x64", "arm64"), "x64") + + self.assertEqual(route.runnable, ("x86", "x64")) + self.assertEqual(route.coverage, ("x64",)) + self.assertEqual(route.asan, ("x86", "x64")) + self.assertEqual(route.ubsan, ("x64",)) + self.assertTrue(route.run_x64_specialists) + self.assertEqual( + [(item.gate, item.architecture) for item in route.deferred], + [("tests", "arm64"), ("package-runtime", "arm64")], + ) + self.assertTrue(all(item.reason for item in route.deferred)) + + def test_verify_route_defers_nonrunnable_specialists_explicitly(self) -> None: + route = verify_route(("x64",), "x86") + + self.assertEqual(route.runnable, ()) + self.assertEqual(route.coverage, ()) + self.assertEqual(route.asan, ()) + self.assertEqual(route.ubsan, ()) + self.assertFalse(route.run_x64_specialists) + self.assertEqual( + [item.gate for item in route.deferred], + ["tests", "package-runtime", "coverage", "asan", "ubsan", "leaks", "fuzz"], + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_instrumented_graph.py b/build/tests/test_instrumented_graph.py new file mode 100644 index 0000000..5090ad0 --- /dev/null +++ b/build/tests/test_instrumented_graph.py @@ -0,0 +1,701 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.paths import BuildPaths # noqa: E402 +from core.graph import Graph, Node # noqa: E402 +from graphs.instrumented import ( # noqa: E402 + InstrumentedVariant, + instrumented_build_slice, + instrumented_dependency_discovery_slice, +) +from graphs.analysis import ( # noqa: E402 + clang_dependency_discovery_slice, +) + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + llvm_dir: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +class InstrumentedBuildGraphTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + project = """ + + + {definition} + +""" + files = { + "src/renpy.cpp": '#include "renpy.h"\n', + "src/renpy.h": "#pragma once\n", + "src/rpgmaker.cpp": '#include "rpgmaker.h"\n', + "src/rpgmaker.h": "#pragma once\n", + "src/zanzarah.cpp": '#include "zanzarah.h"\n', + "src/zanzarah.h": "#pragma once\n", + "src/tests.cpp": '#include "tests.h"\n', + "src/tests.h": "#pragma once\n", + "src/leak-probe.cpp": '#include "tests.h"\n', + "src/renpy.def": "EXPORTS\n OpenStorage\n", + "build/ObserverProjectConfigurations.props": "\n", + "build/ObserverConfiguration.props": "\n", + "build/ObserverProject.props": "\n", + "vcpkg.json": '{"dependencies":["zlib"]}\n', + } + for architecture in ("x86", "x64"): + for flavor in ("", "-asan"): + files[ + f"build/vcpkg/triplets/observer-{architecture}-windows-static{flavor}.cmake" + ] = "set(VCPKG_LIBRARY_LINKAGE static)\n" + for name in ("renpy", "rpgmaker", "zanzarah", "tests"): + definition = ( + "" + "$(RepositoryRoot)src\\renpy.def" + "" + if name == "renpy" + else ( + "" + "" + if name == "rpgmaker" + else "" + ) + ) + files[f"build/projects/{name}.vcxproj"] = project.format( + name=name, definition=definition + ) + files["build/projects/leak-probe.vcxproj"] = project.format( + name="leak-probe", definition="" + ) + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def toolchain(self, root: Path) -> FakeToolchain: + tools = root / "tools" + tools.mkdir() + vcpkg = tools / "vcpkg" + vcpkg.mkdir() + (vcpkg / "vcpkg.exe").touch() + llvm = tools / "llvm" + (llvm / "bin").mkdir(parents=True) + (llvm / "bin/clang-cl.exe").write_bytes(b"clang-v1") + (llvm / "bin/clang-scan-deps.exe").write_bytes(b"scan-v1") + msbuild, pwsh = tools / "MSBuild.exe", tools / "pwsh.exe" + msbuild.touch() + pwsh.touch() + return FakeToolchain( + msbuild, pwsh, vcpkg, llvm, + (("PATH", str(tools)),), + {"msbuild": "17.14", "msvc": "14.44", "llvm": "20.1"}, + ) + + def llvm_runtime(self, root: Path) -> Path: + runtime = root / "llvm-runtime" + runtime.mkdir() + for name in ( + "clang_rt.ubsan_standalone-x86_64.lib", + "clang_rt.ubsan_standalone_cxx-x86_64.lib", + ): + (runtime / name).touch() + return runtime + + def variants(self, runtime: Path) -> tuple[InstrumentedVariant, ...]: + return ( + InstrumentedVariant("coverage", "x64"), + InstrumentedVariant("asan", "x86"), + InstrumentedVariant("asan", "x64"), + InstrumentedVariant( + "ubsan", "x64", runtime, + {"standalone": "sha256:a", "cxx": "sha256:b"}, + ), + ) + + def manifests(self, repository: Path, discovery) -> dict[str, bytes]: + result = {} + for target in discovery.targets: + project = next( + name + for name in ("renpy", "rpgmaker", "zanzarah", "tests") + if f"-{name}-" in target + ) + result[target] = json.dumps( + { + "Data": { + "Source": str((repository / f"src/{project}.cpp").resolve()), + "Includes": [str((repository / f"src/{project}.h").resolve())], + } + } + ).encode() + return result + + def test_canonical_restores_discoveries_and_builds_cover_every_variant_project(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + variants = self.variants(self.llvm_runtime(root)) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants, jobs=8 + ) + graph = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=self.manifests(repository, discovery), + variants=variants, + jobs=8, + ) + paths = BuildPaths(repository) + + names = tuple(item.name for item in discovery.nodes) + self.assertEqual(names.count("restore-vcpkg-x64"), 1) + self.assertEqual(names.count("restore-vcpkg-asan-x86"), 1) + self.assertEqual(names.count("restore-vcpkg-asan-x64"), 1) + self.assertEqual(len(discovery.targets), 16) + clang_capture_count = 4 * sum( + item.kind in {"coverage", "ubsan"} for item in variants + ) + self.assertEqual( + len(discovery.nodes), + 3 + len(discovery.targets) + clang_capture_count, + ) + coverage_discovery = discovery.node( + next(name for name in discovery.targets if "-x64-coverage-renpy-" in name) + ) + ubsan_discovery = discovery.node( + next(name for name in discovery.targets if "-x64-ubsan-renpy-" in name) + ) + asan_discovery = discovery.node( + next(name for name in discovery.targets if "-x64-asan-renpy-" in name) + ).command.stdin.decode("utf-8") + for normalized in (coverage_discovery, ubsan_discovery): + self.assertEqual(len(normalized.inputs), 1) + raw = discovery.node(normalized.inputs[0]) + script = raw.command.stdin.decode("utf-8") + self.assertIn("/p:ObserverClangCommandPath=", script) + self.assertIn("'/p:LLVMInstallDir=", script) + self.assertNotIn("PlatformToolset=v143", script) + self.assertNotIn("ObserverSourceDependenciesPath", script) + self.assertEqual( + normalized.command.argv[1:4], + ("-m", "core.clang_dependencies", "scan"), + ) + self.assertNotIn("PlatformToolset=v143", asan_discovery) + self.assertEqual(len(graph.nodes), len(discovery.nodes) + len(graph.targets)) + self.assertEqual(len(graph.targets), 16) + self.assertEqual(graph.pools, {"restore": 1, "slot": 8}) + + for variant in variants: + restore = ( + f"restore-vcpkg-asan-{variant.architecture}" + if variant.kind == "asan" + else f"restore-vcpkg-{variant.architecture}" + ) + for project in ("renpy", "rpgmaker", "zanzarah", "tests"): + build = graph.node( + f"build-{project}-{variant.architecture}-{variant.kind}" + ) + self.assertEqual(len(build.inputs), 1) + self.assertIn( + f"-{variant.architecture}-{variant.kind}-{project}-", build.inputs[0] + ) + script = build.command.stdin.decode("utf-8") + self.assertIn(f"'/p:Configuration={variant.configuration}'", script) + self.assertIn("'/m:1'", script) + self.assertIn("'/p:BuildProjectReferences=false'", script) + self.assertIn(str(paths.cas(graph.node(restore).uid).output), script) + if variant.kind in {"coverage", "ubsan"}: + self.assertIn("'/p:LLVMInstallDir=", script) + else: + self.assertNotIn("'/p:LLVMInstallDir=", script) + if variant.kind == "ubsan": + self.assertIn("'/p:LLVMRuntimeDir=", script) + else: + self.assertNotIn("'/p:LLVMRuntimeDir=", script) + + def test_invalid_variants_manifests_and_runtime_contracts_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + runtime = self.llvm_runtime(root) + good = (InstrumentedVariant("coverage", "x64"),) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=good + ) + + for variant in ( + InstrumentedVariant("msan", "x64"), + InstrumentedVariant("asan", "arm64"), + InstrumentedVariant("ubsan", "x86", runtime, {"hash": "x"}), + ): + with self.subTest(variant=variant), self.assertRaisesRegex(ValueError, "variant"): + instrumented_dependency_discovery_slice( + repository, toolchain, variants=(variant,) + ) + with self.assertRaisesRegex(ValueError, "duplicate"): + instrumented_dependency_discovery_slice( + repository, toolchain, variants=good + good + ) + with self.assertRaisesRegex(ValueError, "at least one"): + instrumented_dependency_discovery_slice( + repository, toolchain, variants=() + ) + with self.assertRaisesRegex(ValueError, "jobs"): + instrumented_dependency_discovery_slice( + repository, toolchain, variants=good, jobs=True + ) + with self.assertRaisesRegex(ValueError, "runtime"): + instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=(InstrumentedVariant("ubsan", "x64"),), + ) + with self.assertRaisesRegex(ValueError, "runtime"): + instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=(InstrumentedVariant("coverage", "x64", runtime, {"x": "y"}),), + ) + + manifests = self.manifests(repository, discovery) + with self.assertRaisesRegex(ValueError, "missing dependency manifest"): + instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests={}, + variants=good, + ) + with self.assertRaisesRegex(ValueError, "discovery is required"): + instrumented_build_slice( + repository, + toolchain, + discovery=None, + manifests=manifests, + variants=good, + ) + with self.assertRaisesRegex(ValueError, "jobs"): + instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=good, + jobs=0, + ) + conflicting_pool = Graph( + discovery.nodes, + discovery.targets, + {"restore": 1, "slot": 3}, + ) + with self.assertRaisesRegex(ValueError, "conflicting slot"): + instrumented_build_slice( + repository, + toolchain, + discovery=conflicting_pool, + manifests=manifests, + variants=good, + jobs=2, + ) + + project = repository / "build/projects/tests.vcxproj" + project.write_text( + project.read_text(encoding="utf-8").replace( + "$(RepositoryRoot)src\\tests.cpp", "src\\tests.cpp" + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "unsupported project input"): + instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=good, + ) + project.write_text( + project.read_text(encoding="utf-8").replace( + "src\\tests.cpp", "$(RepositoryRoot)src\\tests.cpp" + ), + encoding="utf-8", + ) + + empty_runtime = root / "empty-runtime" + empty_runtime.mkdir() + with self.assertRaisesRegex(FileNotFoundError, "UBSan runtime library"): + instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=( + InstrumentedVariant( + "ubsan", "x64", empty_runtime, {"hash": "x"} + ), + ), + ) + + original = discovery.node("restore-vcpkg-x64") + conflicting = Node( + original.name, + "0" * 32, + original.pool, + original.command, + original.inputs, + ) + conflict_graph = Graph( + (conflicting,), (conflicting.name,), {"restore": 1, "slot": 2} + ) + with ( + mock.patch( + "graphs.instrumented.clang_dependency_discovery_slice", + side_effect=(discovery, conflict_graph), + ), + self.assertRaisesRegex(ValueError, "conflicting canonical node"), + ): + instrumented_dependency_discovery_slice( + repository, + toolchain, + variants=( + InstrumentedVariant("coverage", "x64"), + InstrumentedVariant( + "ubsan", "x64", runtime, + {"standalone": "a", "cxx": "b"}, + ), + ), + ) + + def test_source_toolchain_and_ubsan_runtime_identities_invalidate_exact_consumers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + runtime = self.llvm_runtime(root) + variants = ( + InstrumentedVariant("coverage", "x64"), + InstrumentedVariant("asan", "x64"), + InstrumentedVariant("ubsan", "x64", runtime, {"hash": "before"}), + ) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + manifests = self.manifests(repository, discovery) + before = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=variants, + ) + clang = toolchain.llvm_dir / "bin/clang-cl.exe" + clang.write_bytes(b"clang-v2") + clang_discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + clang_changed = instrumented_build_slice( + repository, + toolchain, + discovery=clang_discovery, + manifests=manifests, + variants=variants, + ) + clang.write_bytes(b"clang-v1") + scanner = toolchain.llvm_dir / "bin/clang-scan-deps.exe" + scanner.write_bytes(b"scan-v2") + scanner_discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + scanner_changed = instrumented_build_slice( + repository, + toolchain, + discovery=scanner_discovery, + manifests=manifests, + variants=variants, + ) + scanner.write_bytes(b"scan-v1") + changed_variants = ( + variants[0], + variants[1], + InstrumentedVariant("ubsan", "x64", runtime, {"hash": "after"}), + ) + runtime_changed = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=changed_variants, + ) + (repository / "src/renpy.h").write_text( + "#pragma once\n// changed\n", encoding="utf-8" + ) + source_changed = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=variants, + ) + changed_toolchain = replace( + toolchain, identity=toolchain.identity | {"llvm": "20.2"} + ) + changed_discovery = instrumented_dependency_discovery_slice( + repository, changed_toolchain, variants=variants + ) + toolchain_changed = instrumented_build_slice( + repository, + changed_toolchain, + discovery=changed_discovery, + manifests=manifests, + variants=variants, + ) + + for kind in ("coverage", "asan", "ubsan"): + for project in ("renpy", "rpgmaker", "zanzarah", "tests"): + name = f"build-{project}-x64-{kind}" + self.assertEqual( + kind == "ubsan", + before.node(name).uid != runtime_changed.node(name).uid, + name, + ) + self.assertEqual( + kind in {"coverage", "ubsan"}, + before.node(name).uid != clang_changed.node(name).uid, + name, + ) + self.assertEqual( + kind in {"coverage", "ubsan"}, + before.node(name).uid != scanner_changed.node(name).uid, + name, + ) + self.assertEqual( + project == "renpy", + before.node(name).uid != source_changed.node(name).uid, + name, + ) + self.assertNotEqual( + before.node(name).uid, toolchain_changed.node(name).uid, name + ) + + def test_clang_discovery_and_build_uids_ignore_manifest_cache_state(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + runtime = self.llvm_runtime(root) + variants = ( + InstrumentedVariant("coverage", "x64"), + InstrumentedVariant( + "ubsan", "x64", runtime, {"hash": "runtime"} + ), + ) + cold = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + manifests = self.manifests(repository, cold) + cold_build = instrumented_build_slice( + repository, + toolchain, + discovery=cold, + manifests=manifests, + variants=variants, + ) + paths = BuildPaths(repository) + first = cold.targets[0] + cas = paths.cas(cold.node(first).uid) + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes(manifests[first]) + cas.touch.touch() + partial = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + partial_build = instrumented_build_slice( + repository, + toolchain, + discovery=partial, + manifests=manifests, + variants=variants, + ) + for target in cold.targets[1:]: + cas = paths.cas(cold.node(target).uid) + cas.output.mkdir(parents=True) + (cas.output / "dependencies.json").write_bytes(manifests[target]) + cas.touch.touch() + full = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + full_build = instrumented_build_slice( + repository, + toolchain, + discovery=full, + manifests=manifests, + variants=variants, + ) + + expected_discovery = {node.name: node.uid for node in cold.nodes} + expected_build = {node.name: node.uid for node in cold_build.nodes} + self.assertEqual( + expected_discovery, + {node.name: node.uid for node in partial.nodes}, + ) + self.assertEqual( + expected_discovery, + {node.name: node.uid for node in full.nodes}, + ) + self.assertEqual( + expected_build, + {node.name: node.uid for node in partial_build.nodes}, + ) + self.assertEqual( + expected_build, + {node.name: node.uid for node in full_build.nodes}, + ) + + def test_clang_discovery_signs_stable_header_coverage(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + before = clang_dependency_discovery_slice( + repository, + toolchain, + project_names=("renpy",), + configuration="Coverage", + name_qualifier="coverage", + ) + (repository / "src/renpy.h").write_text( + "#pragma once\n// dependency topology may have changed\n", + encoding="utf-8", + ) + after = clang_dependency_discovery_slice( + repository, + toolchain, + project_names=("renpy",), + configuration="Coverage", + name_qualifier="coverage", + ) + + target = before.targets[0] + self.assertNotEqual(before.node(target).uid, after.node(target).uid) + before_capture = before.node(before.node(target).inputs[0]) + after_capture = after.node(after.node(target).inputs[0]) + self.assertNotEqual(before_capture.uid, after_capture.uid) + + def test_instrumented_build_requires_declared_cross_directory_dependency(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + shared = repository / "src/shared/topology.h" + shared.parent.mkdir() + shared.write_text("#pragma once\n", encoding="utf-8") + toolchain = self.toolchain(root) + variants = (InstrumentedVariant("coverage", "x64"),) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + manifests = self.manifests(repository, discovery) + for target in discovery.targets: + if "-renpy-" in target: + manifests[target] = json.dumps( + { + "Data": { + "Source": str( + (repository / "src/renpy.cpp").resolve() + ), + "Includes": [str(shared.resolve())], + } + } + ).encode() + + with self.assertRaisesRegex( + ValueError, "first-party dependency is not covered.*src/shared/topology.h" + ): + instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=manifests, + variants=variants, + ) + + project = repository / "build/projects/renpy.vcxproj" + project.write_text( + project.read_text(encoding="utf-8").replace( + "", + " \n", + ), + encoding="utf-8", + ) + declared_discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + declared = instrumented_build_slice( + repository, + toolchain, + discovery=declared_discovery, + manifests=manifests, + variants=variants, + ) + + self.assertIn("build-renpy-x64-coverage", declared.targets) + + def test_clang_discovery_skips_unsupported_leak_architecture(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery = clang_dependency_discovery_slice( + repository, + self.toolchain(root), + project_names=("leak-probe",), + configuration="Coverage", + name_qualifier="coverage", + architectures=("x86", "x64"), + ) + + self.assertEqual(len(discovery.targets), 1) + self.assertIn("-x64-coverage-leak-probe-", discovery.targets[0]) + + def test_asan_only_build_does_not_require_clang(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + variants = (InstrumentedVariant("asan", "x64"),) + discovery = instrumented_dependency_discovery_slice( + repository, toolchain, variants=variants + ) + + with mock.patch( + "graphs.instrumented.resolve_llvm", + side_effect=AssertionError("ASan must stay on the MSVC path"), + ): + graph = instrumented_build_slice( + repository, + toolchain, + discovery=discovery, + manifests=self.manifests(repository, discovery), + variants=variants, + ) + + self.assertEqual(len(graph.targets), 4) + self.assertTrue(all(name.endswith("-x64-asan") for name in graph.targets)) + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_leak_graph.py b/build/tests/test_leak_graph.py new file mode 100644 index 0000000..af9a250 --- /dev/null +++ b/build/tests/test_leak_graph.py @@ -0,0 +1,737 @@ +from __future__ import annotations + +import hashlib +import io +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from core.graph import Command, Graph, Node # noqa: E402 +import core.leak as leak # noqa: E402 +from core.leak import LeakError, main as leak_main # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from graphs.leak import LEAK_MODES, LEAK_SCENARIOS, leak_graph # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "build", + Command(("build.exe",)), + inputs, + ) + + +class LeakGraphTests(unittest.TestCase): + def fixture( + self, root: Path + ) -> tuple[Path, Graph, Path]: + repository = root / "repo" + repository.mkdir() + restore = node("restore-release") + builds = tuple( + node(f"build-{name}-x64-release", inputs=(restore.name,)) + for name in ("leak-probe", "renpy", "rpgmaker", "zanzarah") + ) + audit_gates = tuple( + node(f"audit-{kind}-x64-{module}", inputs=(producer.name,)) + for module, producer in zip( + ("leak-probe", "renpy", "rpgmaker", "zanzarah"), builds, strict=True + ) + for kind in ("pe", "binskim") + ) + upstream = Graph((restore, *builds, *audit_gates), tuple(item.name for item in builds), {"build": 4}) + tools = root / "tools" + tools.mkdir() + umdh = tools / "umdh.exe" + umdh.touch() + return repository, upstream, umdh + + def build_graph(self, root: Path, **options: object) -> Graph: + repository, upstream, umdh = self.fixture(root) + return leak_graph( + repository, + upstream, + umdh=umdh, + umdh_identity={"version": "10.0"}, + run_nonce="run-42", + **options, + ) + + def test_default_graph_has_one_shared_setup_and_fourteen_independent_branches(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + graph = self.build_graph(Path(temporary), jobs=7) + + leak_nodes = tuple(item for item in graph.nodes if item.name.startswith("leak-")) + self.assertEqual(99, len(leak_nodes)) + self.assertEqual( + graph.pools, + {"build": 4, "slot": 7}, + ) + self.assertEqual( + graph.targets, + tuple( + f"leak-gate-{mode}-{scenario}" + for mode in LEAK_MODES + for scenario in LEAK_SCENARIOS + ), + ) + + setup = graph.node("leak-setup-x64-release") + self.assertEqual( + setup.inputs, + ("build-leak-probe-x64-release",) + + tuple( + dependency + for module in ("renpy", "rpgmaker", "zanzarah") + for dependency in ( + f"build-{module}-x64-release", + f"audit-pe-x64-{module}", + f"audit-binskim-x64-{module}", + ) + ), + ) + self.assertEqual(setup.command.argv[1:4], ("-m", "core.leak", "setup")) + + for mode in LEAK_MODES: + for scenario in LEAK_SCENARIOS: + stem = f"{mode}-{scenario}" + preflight = graph.node(f"leak-preflight-{stem}") + capture = graph.node(f"leak-capture-{stem}") + adjacent = tuple( + graph.node(f"leak-diff-{stem}-window-{index}") for index in (1, 2) + ) + overall = graph.node(f"leak-diff-{stem}-overall") + summary = graph.node(f"leak-summary-{stem}") + gate = graph.node(f"leak-gate-{stem}") + + self.assertEqual(preflight.inputs, (setup.name,)) + self.assertEqual(capture.inputs, (preflight.name,)) + self.assertTrue(all(item.inputs == (capture.name,) for item in (*adjacent, overall))) + self.assertEqual(summary.inputs, tuple(item.name for item in (*adjacent, overall))) + self.assertEqual(gate.inputs, (summary.name,)) + self.assertEqual( + (preflight.pool, capture.pool, adjacent[0].pool, summary.pool, gate.pool), + ("slot", "slot", "slot", "slot", "slot"), + ) + self.assertEqual(preflight.command.argv[-2:], (mode, scenario)) + self.assertEqual(capture.command.argv[6:8], (mode, scenario)) + self.assertEqual((overall.command.argv[3], overall.command.argv[6]), ("diff", "overall")) + self.assertEqual(summary.command.argv[9], "0") + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in preflight.results), + ((f"reports/leak/x64/{mode}/{scenario}/preflight.json", + "leak", "application/json", "preflight.json"),), + ) + self.assertEqual( + tuple((item.id, item.relative_path) for item in capture.results), + ( + (f"reports/leak/x64/{mode}/{scenario}/capture.json", "capture.json"), + (f"reports/leak/x64/{mode}/{scenario}/snapshots", "snapshots"), + (f"reports/leak/x64/{mode}/{scenario}/probe.stderr.log", "probe.stderr.log"), + ), + ) + evidence = tuple(zip(("window-1", "window-2"), adjacent, strict=True)) + ( + ("overall", overall), + ) + for label, item in evidence: + self.assertEqual( + tuple((result.id, result.relative_path) for result in item.results), + ( + (f"reports/leak/x64/{mode}/{scenario}/diffs/{label}.json", "diff.json"), + (f"reports/leak/x64/{mode}/{scenario}/diffs/{label}.txt", "report.txt"), + ), + ) + self.assertEqual( + tuple((item.id, item.relative_path) for item in summary.results), + ((f"reports/leak/x64/{mode}/{scenario}/summary.json", "summary.json"),), + ) + self.assertEqual(gate.results, ()) + + def test_measurement_options_expand_snapshot_diffs_and_are_signed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + graph = self.build_graph( + root, warmup=2, iterations=3, windows=4, tolerance_bytes=17 + ) + + self.assertEqual(113, len(tuple(item for item in graph.nodes if item.name.startswith("leak-")))) + capture = graph.node("leak-capture-operations-small-success") + self.assertEqual(capture.command.argv[-3:], ("2", "3", "4")) + summary = graph.node("leak-summary-operations-small-success") + self.assertEqual( + summary.inputs, + ( + "leak-diff-operations-small-success-window-1", + "leak-diff-operations-small-success-window-2", + "leak-diff-operations-small-success-window-3", + "leak-diff-operations-small-success-overall", + ), + ) + self.assertEqual(summary.command.argv[3:10], ("summarize", "operations", "small-success", "2", "3", "4", "17")) + + def test_run_and_tool_identities_invalidate_only_the_measurement_partition(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, umdh = self.fixture(root) + + def graph( + *, + nonce: str = "run-a", + umdh_version: str = "10.0", + tolerance: int = 4096, + ) -> Graph: + return leak_graph( + repository, + upstream, + umdh=umdh, + umdh_identity={"version": umdh_version}, + run_nonce=nonce, + tolerance_bytes=tolerance, + ) + + before = graph() + rerun = graph(nonce="run-b") + new_umdh = graph(umdh_version="10.1") + new_tolerance = graph(tolerance=8192) + + for current in before.nodes: + if not current.name.startswith("leak-"): + continue + measured = current.name.startswith(("leak-capture-", "leak-diff-", "leak-summary-", "leak-gate-")) + judged = current.name.startswith(("leak-summary-", "leak-gate-")) + self.assertEqual(measured, current.uid != rerun.node(current.name).uid, current.name) + self.assertEqual(measured, current.uid != new_umdh.node(current.name).uid, current.name) + self.assertEqual(judged, current.uid != new_tolerance.node(current.name).uid, current.name) + + def test_rejects_invalid_tools_lineage_pools_and_measurement_options(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, umdh = self.fixture(root) + + def invoke( + *, + source: Graph = upstream, + umdh_path: Path = umdh, + nonce: object = "run-42", + warmup: object = 8, + iterations: object = 100, + windows: object = 3, + tolerance: object = 4096, + jobs: object = 4, + ) -> Graph: + return leak_graph( + repository, + source, + umdh=umdh_path, + umdh_identity={"version": "10.0"}, + run_nonce=nonce, # type: ignore[arg-type] + warmup=warmup, # type: ignore[arg-type] + iterations=iterations, # type: ignore[arg-type] + windows=windows, # type: ignore[arg-type] + tolerance_bytes=tolerance, # type: ignore[arg-type] + jobs=jobs, # type: ignore[arg-type] + ) + + for jobs in (True, "four", 0): + with self.subTest(jobs=jobs), self.assertRaisesRegex( + ValueError, "capacities" + ): + invoke(jobs=jobs) + for counts in ((True, 100, 3), (8, "many", 3), (8, 100, 0)): + with self.subTest(counts=counts), self.assertRaisesRegex(ValueError, "counts"): + invoke(warmup=counts[0], iterations=counts[1], windows=counts[2]) + with self.assertRaisesRegex(ValueError, "at least three"): + invoke(windows=2) + for tolerance in (True, "zero", -1): + with self.subTest(tolerance=tolerance), self.assertRaisesRegex(ValueError, "tolerance"): + invoke(tolerance=tolerance) + for nonce in (7, "", "bad\0nonce"): + with self.subTest(nonce=nonce), self.assertRaisesRegex(ValueError, "nonce"): + invoke(nonce=nonce) + + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(umdh_path=umdh.parent) + + missing_names = { + "build-leak-probe-x64-release", + "audit-pe-x64-leak-probe", + "audit-binskim-x64-leak-probe", + } + missing_build = Graph( + tuple(item for item in upstream.nodes if item.name not in missing_names), + tuple(name for name in upstream.targets if name != "build-leak-probe-x64-release"), + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "unknown node"): + invoke(source=missing_build) + + missing_gate = Graph( + tuple(node for node in upstream.nodes if node.name != "audit-binskim-x64-renpy"), + upstream.targets, + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "audit gates"): + invoke(source=missing_gate) + + matching = Graph( + upstream.nodes, + upstream.targets, + {"build": 4, "slot": 4}, + ) + self.assertEqual(4, invoke(source=matching).pools["slot"]) + conflicting = Graph(upstream.nodes, upstream.targets, {"build": 4, "slot": 1}) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + invoke(source=conflicting) + + +class FakeProbe: + def __init__(self, lines: list[str], *, result: int = 0, timeout: bool = False) -> None: + class Stream(io.StringIO): + def close(stream) -> None: + stream.was_closed = True + + self.stdout = Stream("\n".join(lines) + "\n") + self.stdin = Stream() + self.pid = 77 + self.result = result + self.timeout = timeout + self.returncode: int | None = None + self.killed = False + self.descendant = mock.Mock() + + def wait(self, timeout: int = 0) -> int: + if self.timeout: + raise leak.psutil.TimeoutExpired(timeout, self.pid) + self.returncode = self.result + return self.result + + def poll(self) -> int | None: + return self.returncode + + def children(self, *, recursive: bool) -> list[object]: + self.recursive = recursive + return [self.descendant] + + def kill(self) -> None: + self.killed = True + self.returncode = -9 + + +class LeakWorkerTests(unittest.TestCase): + def output(self, root: Path): + return mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(root)}, clear=False) + + def test_setup_stages_exact_binaries_symbols_and_hash_evidence(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "out" + output.mkdir() + sources = [] + for index, name in enumerate(leak.BINARIES): + directory = root / str(index) + directory.mkdir() + source = directory / name + source.write_bytes(name.encode()) + (directory / f"{index}.pdb").write_bytes(bytes([index])) + sources.append(source) + with self.output(output): + self.assertEqual(0, leak_main(("setup", *(str(path) for path in sources)))) + evidence = json.loads((output / "release-binaries.json").read_text(encoding="utf-8")) + + self.assertEqual(list(leak.BINARIES), [item["name"] for item in evidence["binaries"]]) + self.assertEqual("MT_StaticRelease", evidence["runtimeLibrary"]) + self.assertTrue(all((output / name).is_file() for name in leak.BINARIES)) + self.assertTrue(all((output / f"{index}.pdb").is_file() for index in range(4))) + + with self.output(output), self.assertRaisesRegex(LeakError, "not found"): + leak_main(("setup", *(str(path) for path in (*sources[:-1], root / "missing")))) + + def test_preflight_uses_communicate_safe_capture_and_checks_markers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "leak-probe.exe").touch() + ready = "OBSERVER_LEAK_PROBE|READY|pid=12|mode=operations|configuration=Release|scenarios=malformed" + complete = subprocess.CompletedProcess([], 0, ready + "\nOBSERVER_LEAK_PROBE|DONE|pid=12\n") + with self.output(root), mock.patch("core.leak.subprocess.run", return_value=complete) as invoked: + leak_main(("preflight", str(root), "operations", "malformed")) + self.assertIs(invoked.call_args.kwargs["stderr"], subprocess.STDOUT) + self.assertIn("--automatic", invoked.call_args.args[0]) + + failures = ( + subprocess.CompletedProcess([], 2, "broken"), + subprocess.CompletedProcess([], 0, ready), + subprocess.CompletedProcess([], 0, ready.replace("malformed", "read-failure") + "\nOBSERVER_LEAK_PROBE|DONE|pid=12"), + ) + for result in failures: + with self.subTest(result=result), self.output(root), mock.patch( + "core.leak.subprocess.run", return_value=result + ), self.assertRaises(LeakError): + leak_main(("preflight", str(root), "operations", "malformed")) + + def test_capture_streams_stdout_redirects_stderr_to_file_and_uses_psutil(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "leak-probe.exe").touch() + umdh = root / "umdh.exe" + umdh.touch() + gflags = root / "gflags.exe" + gflags.touch() + lines = [ + "probe startup noise", + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|completed_operations=1" for label in ("baseline", "window-1", "window-2", "window-3")), + "OBSERVER_LEAK_PROBE|DONE|pid=77|completed_operations=4", + ] + process = FakeProbe(lines) + + def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + if Path(argv[0]) == gflags: + output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else "" + return subprocess.CompletedProcess(argv, 0, output) + self.assertEqual(str(root), _options["env"]["_NT_SYMBOL_PATH"]) + self.assertEqual("1", _options["env"]["OANOCACHE"]) + Path(argv[-1].removeprefix("-f:")).write_text("BackTrace 1\n", encoding="utf-8") + return subprocess.CompletedProcess(argv, 0, "") + + with self.output(root), mock.patch("core.leak.psutil.Popen", return_value=process) as popen, mock.patch( + "core.leak.subprocess.run", side_effect=snapshot + ) as invoked: + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + + commands = [call.args[0] for call in invoked.call_args_list] + self.assertEqual( + commands[:2], + [ + [str(gflags), "/i", "leak-probe.exe"], + [str(gflags), "/i", "leak-probe.exe", "+ust"], + ], + ) + self.assertEqual([str(gflags), "/i", "leak-probe.exe", "-ust"], commands[2]) + self.assertIsNot(popen.call_args.kwargs["stderr"], subprocess.PIPE) + self.assertEqual("continue|baseline\ncontinue|window-1\ncontinue|window-2\ncontinue|window-3\n", process.stdin.getvalue()) + self.assertTrue(process.stdin.was_closed) + self.assertTrue(process.stdout.was_closed) + self.assertEqual(77, json.loads((root / "capture.json").read_text())["processId"]) + + def test_capture_kills_the_probe_tree_on_protocol_timeout_or_exit_failure(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "leak-probe.exe").touch() + umdh = root / "umdh.exe" + umdh.touch() + gflags = root / "gflags.exe" + gflags.touch() + cases = ( + FakeProbe(["OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed"]), + FakeProbe([ + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + "OBSERVER_LEAK_PROBE|SNAPSHOT|baseline|pid=12|", + ]), + FakeProbe([ + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in ("baseline", "window-1", "window-2", "window-3")), + "OBSERVER_LEAK_PROBE|DONE|pid=77|", + ], timeout=True), + FakeProbe([ + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in ("baseline", "window-1", "window-2", "window-3")), + "OBSERVER_LEAK_PROBE|DONE|pid=77|", + ], result=5), + ) + + def snapshot(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + if Path(argv[0]) == gflags: + output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else "" + return subprocess.CompletedProcess(argv, 0, output) + Path(argv[-1].removeprefix("-f:")).write_text("BackTrace\n", encoding="utf-8") + return subprocess.CompletedProcess(argv, 0, "") + + for index, process in enumerate(cases): + output = root / f"output-{index}" + output.mkdir() + with self.subTest(process=process), self.output(output), mock.patch( + "core.leak.psutil.Popen", return_value=process + ), mock.patch("core.leak.psutil.wait_procs"), mock.patch( + "core.leak.subprocess.run", side_effect=snapshot + ), self.assertRaises(LeakError): + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + if process.timeout or process.result == 0: + self.assertTrue(process.killed) + + rejected = root / "gflags-rejected" + rejected.mkdir() + failure = subprocess.CompletedProcess([], 1, "access denied") + + def reject(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + if len(argv) == 3: + return subprocess.CompletedProcess( + argv, 0, "Current Registry Settings for leak-probe.exe executable are: 00000000" + ) + return failure + + with self.output(rejected), mock.patch( + "core.leak.subprocess.run", side_effect=reject + ), mock.patch("core.leak.psutil.Popen") as popen, self.assertRaisesRegex( + LeakError, "GFlags \\+ust failed" + ): + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + popen.assert_not_called() + + cleanup = root / "gflags-cleanup" + cleanup.mkdir() + + def reject_cleanup(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + output = "Current Registry Settings for leak-probe.exe executable are: 00000000" if len(argv) == 3 else "" + return failure if argv[-1] == "-ust" else subprocess.CompletedProcess(argv, 0, output) + + process = FakeProbe([]) + with self.output(cleanup), mock.patch( + "core.leak.subprocess.run", side_effect=reject_cleanup + ), mock.patch("core.leak.psutil.Popen", return_value=process), mock.patch( + "core.leak.psutil.wait_procs" + ), self.assertRaisesRegex(LeakError, "GFlags -ust failed"): + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + self.assertTrue(process.killed) + + elevation = root / "gflags-elevation" + elevation.mkdir() + elevated = OSError("requires elevation") + elevated.winerror = 740 # type: ignore[attr-defined] + calls = 0 + + def unavailable(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + nonlocal calls + if Path(argv[0]) == gflags: + calls += 1 + if calls == 1: + return subprocess.CompletedProcess( + argv, 0, "Current Registry Settings for leak-probe.exe executable are: 00000000" + ) + raise elevated + Path(argv[-1].removeprefix("-f:")).write_text("BackTrace\n", encoding="utf-8") + return subprocess.CompletedProcess(argv, 0, "") + + process = FakeProbe([ + "OBSERVER_LEAK_PROBE|READY|pid=77|mode=operations|configuration=Release|scenarios=malformed", + *(f"OBSERVER_LEAK_PROBE|SNAPSHOT|{label}|pid=77|" for label in ("baseline", "window-1", "window-2", "window-3")), + "OBSERVER_LEAK_PROBE|DONE|pid=77|", + ]) + with self.output(elevation), mock.patch( + "core.leak.subprocess.run", side_effect=unavailable + ), mock.patch("core.leak.psutil.Popen", return_value=process) as popen: + leak_main(("capture", str(root), str(umdh), "operations", "malformed", "1", "2", "3")) + popen.assert_called_once() + + existing = mock.Mock(returncode=0, stdout="Current Registry Settings for leak-probe.exe executable are: 00001000") + with mock.patch("core.leak.subprocess.run", return_value=existing) as invoked: + self.assertFalse(leak._enable_stack_traces(gflags, "leak-probe.exe")) + invoked.assert_called_once() + + def test_stack_trace_activation_handles_missing_and_invalid_gflags(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + missing = root / "missing.exe" + self.assertFalse(leak._enable_stack_traces(missing, "probe.exe")) + + gflags = root / "gflags.exe" + gflags.touch() + absent = mock.Mock(returncode=0, stdout="No Registry Settings for probe.exe executable") + changed = mock.Mock(returncode=0, stdout="") + with mock.patch("core.leak.subprocess.run", side_effect=(absent, changed)): + self.assertTrue(leak._enable_stack_traces(gflags, "probe.exe")) + + dual_view = mock.Mock( + returncode=0, + stdout="Current Registry Settings for probe.exe executable are: 00000000 : 00000000", + ) + with mock.patch("core.leak.subprocess.run", side_effect=(dual_view, changed)): + self.assertTrue(leak._enable_stack_traces(gflags, "probe.exe")) + + for result, message in ( + (mock.Mock(returncode=1, stdout="denied"), "query failed"), + (mock.Mock(returncode=0, stdout="unexpected"), "unrecognized"), + ): + with self.subTest(message=message), mock.patch( + "core.leak.subprocess.run", return_value=result + ), self.assertRaisesRegex(LeakError, message): + leak._enable_stack_traces(gflags, "probe.exe") + + query = mock.Mock(returncode=0, stdout="Current Registry Settings for probe.exe executable are: 00000000") + unexpected = OSError("unexpected launch failure") + unexpected.winerror = 5 # type: ignore[attr-defined] + with mock.patch("core.leak.subprocess.run", side_effect=(query, unexpected)), self.assertRaises(OSError): + leak._enable_stack_traces(gflags, "probe.exe") + + def test_diff_and_judge_preserve_growth_evidence_and_reject_leaks(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + report_text = "\n".join(("+ 500 (x) 1 allocs BackTrace ABC", "Total increase == 500")) + + def compare(argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + Path(argv[-1].removeprefix("-f:")).write_text(report_text, encoding="utf-8") + return subprocess.CompletedProcess(argv, 0, "") + + with self.output(root), mock.patch("core.leak.subprocess.run", side_effect=compare): + leak_main(("diff", "umdh", str(root), "window-1", "before", "after")) + first = root / "diff.json" + self.assertEqual(500, json.loads(first.read_text())["positiveStacks"]["ABC"]) + + paths = [] + for label, total in (("window-1", 500), ("window-2", 500), ("overall", 1001)): + path = root / f"{label}.json" + path.write_text(json.dumps({"label": label, "totalIncrease": total, "positiveStacks": {"ABC": 500}})) + paths.extend((label, str(path))) + with self.output(root), self.assertRaisesRegex(LeakError, "sustained"): + leak_main(("judge", "operations", "malformed", "1", "2", "3", "100", *paths)) + self.assertFalse(json.loads((root / "summary.json").read_text())["passed"]) + with self.output(root): + leak_main(("summarize", "operations", "malformed", "1", "2", "3", "100", *paths)) + with self.assertRaisesRegex(LeakError, "sustained"): + leak_main(("gate", str(root / "summary.json"))) + + for path in (root / "window-1.json", root / "window-2.json", root / "overall.json"): + document = json.loads(path.read_text()) + document["totalIncrease"] = 0 + document["positiveStacks"] = {} + path.write_text(json.dumps(document)) + with self.output(root): + leak_main(("judge", "operations", "malformed", "1", "2", "3", "100", *paths)) + leak_main(("gate", str(root / "summary.json"))) + self.assertTrue(json.loads((root / "summary.json").read_text())["passed"]) + + def test_worker_rejects_invalid_arguments_protocol_and_umdh_evidence(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(LeakError, "OBSERVER_OUT_DIR"): + leak._output() + with self.assertRaisesRegex(LeakError, "setup expects"): + leak_main(("setup",)) + with self.assertRaisesRegex(LeakError, "not found"): + leak_main(("preflight", "missing", "operations", "malformed")) + for mode, scenario in (("bad", "malformed"), ("operations", "bad")): + with self.subTest(selection=(mode, scenario)), self.assertRaisesRegex(LeakError, "selection"): + leak._selection(mode, scenario) + for value, message in (("many", "integer"), ("0", "at least")): + with self.subTest(value=value), self.assertRaisesRegex(LeakError, message): + leak._count(value, "rounds") + with self.assertRaisesRegex(LeakError, "requested process"): + leak._ready( + "OBSERVER_LEAK_PROBE|READY|pid=2|mode=operations|configuration=Release|scenarios=malformed", + "operations", "malformed", 1, + ) + with self.assertRaisesRegex(LeakError, "expected leak action"): + leak_main(()) + with self.assertRaisesRegex(LeakError, "expected leak action"): + leak_main(("unknown",)) + with mock.patch.object(sys, "argv", ["leak.py", "unknown"]), self.assertRaises(LeakError): + leak_main() + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + invalid = root / "summary.json" + with self.output(root): + with self.assertRaisesRegex(LeakError, "summary is invalid"): + leak_main(("gate", str(invalid))) + invalid.write_text('{"passed":"yes"}', encoding="utf-8") + with self.assertRaisesRegex(LeakError, "summary is invalid"): + leak_main(("gate", str(invalid))) + + process = FakeProbe([]) + process.descendant.kill.side_effect = leak.psutil.NoSuchProcess(88) + with mock.patch("core.leak.psutil.wait_procs") as waited: + leak._kill_tree(process) # type: ignore[arg-type] + waited.assert_called_once() + self.assertTrue(process.killed) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + destination = root / "snapshot.txt" + bad_results = ( + (subprocess.CompletedProcess([], 2, "bad"), True, ""), + (subprocess.CompletedProcess([], 1, "bad"), True, "wrong"), + (subprocess.CompletedProcess([], 1, "bad"), False, "BackTrace"), + (subprocess.CompletedProcess([], 0, ""), False, "database is full BackTrace"), + (subprocess.CompletedProcess([], 0, ""), False, "empty"), + ) + for result, baseline, content in bad_results: + if content: + destination.write_text(content, encoding="utf-8") + elif destination.exists(): + destination.unlink() + with self.subTest(snapshot=(result.returncode, baseline, content)), mock.patch( + "core.leak.subprocess.run", return_value=result + ), self.assertRaises(LeakError): + leak._snapshot(Path("umdh"), 1, destination, baseline, {}) + + report = root / "report.txt" + + def diff_result(returncode: int, content: str | None): + def run(_argv: list[str], **_options: object) -> subprocess.CompletedProcess[str]: + if content is not None: + report.write_text(content, encoding="utf-8") + elif report.exists(): + report.unlink() + return subprocess.CompletedProcess([], returncode, "bad") + + return run + + with self.output(root): + for returncode, content, message in ( + (2, None, "failed"), + (0, None, "failed"), + (0, "no totals", "no total"), + ): + with self.subTest(diff=(returncode, content)), mock.patch( + "core.leak.subprocess.run", side_effect=diff_result(returncode, content) + ), self.assertRaisesRegex(LeakError, message): + leak_main(("diff", "umdh", str(root), "window-1", "before", "after")) + with mock.patch( + "core.leak.subprocess.run", + side_effect=diff_result(0, "Total decrease == 7"), + ): + leak_main(("diff", "umdh", str(root), "window-1", "before", "after")) + self.assertEqual(-7, json.loads((root / "diff.json").read_text())["totalIncrease"]) + + def test_judge_rejects_shape_and_label_mismatches(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + documents = [] + for label in ("window-1", "window-2", "overall"): + path = root / f"{label}.json" + path.write_text(json.dumps({"label": label, "totalIncrease": 0, "positiveStacks": {}})) + documents.extend((label, str(path))) + with self.output(root): + for arguments in ( + ("judge", "operations"), + ("judge", "operations", "malformed", "1", "2", "3", "0", "window-1", "x", "extra"), + ): + with self.subTest(arguments=arguments), self.assertRaisesRegex(LeakError, "judge expects"): + leak_main(arguments) + with self.assertRaisesRegex(LeakError, "labels"): + leak_main(("judge", "operations", "malformed", "1", "2", "3", "0", "wrong", *documents[1:])) + first = Path(documents[1]) + value = json.loads(first.read_text()) + value["label"] = "wrong" + first.write_text(json.dumps(value)) + with self.assertRaisesRegex(LeakError, "labels"): + leak_main(("judge", "operations", "malformed", "1", "2", "3", "0", *documents)) + + with ( + mock.patch.object(sys, "argv", ["leak.py", "setup"]), + self.assertWarnsRegex(RuntimeWarning, "core.leak"), + self.assertRaises(RuntimeError), + ): + runpy.run_module("core.leak", run_name="__main__") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_main.py b/build/tests/test_main.py new file mode 100644 index 0000000..3a81305 --- /dev/null +++ b/build/tests/test_main.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +import runpy +import re +from types import SimpleNamespace +import sys +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +import main # noqa: E402 + + +class FakeDriver: + instances: list[FakeDriver] = [] + + def __init__(self, *args: object, **kwargs: object) -> None: + self.constructor = (args, kwargs) + self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] + FakeDriver.instances.append(self) + + def __getattr__(self, name: str): + async def invoke(*args: object, **kwargs: object) -> tuple[Path, ...]: + self.calls.append((name, args, kwargs)) + return (Path("out") / name,) + return invoke + + +class MainTests(unittest.TestCase): + def setUp(self) -> None: + FakeDriver.instances.clear() + + def invoke(self, argv: list[str], *, deferred: tuple[object, ...] = ()): + toolchain = object() + source_tools = object() + dumpbin, binskim, umdh = object(), object(), object() + stdout = StringIO() + patches = ( + mock.patch.object(main, "BuildDriver", FakeDriver), + mock.patch.object(main, "_run_id", return_value="run-id"), + mock.patch.object(main, "discover_msvc_toolchain", return_value=toolchain), + mock.patch.object(main, "discover_source_tools", return_value=source_tools), + mock.patch.object(main, "resolve_dumpbin", return_value=dumpbin), + mock.patch.object(main, "resolve_binskim", return_value=binskim), + mock.patch.object(main, "resolve_umdh", return_value=umdh), + mock.patch.object(main, "verify_route", return_value=SimpleNamespace(deferred=deferred)), + ) + with ( + patches[0] as driver, patches[1], patches[2] as discover, + patches[3] as source, patches[4] as find_dumpbin, + patches[5] as find_binskim, patches[6] as find_umdh, + patches[7] as route, redirect_stdout(stdout), + ): + result = main.main(argv) + return SimpleNamespace( + result=result, stdout=stdout.getvalue(), driver=driver, discover=discover, + source=source, dumpbin=find_dumpbin, binskim=find_binskim, umdh=find_umdh, + route=route, source_tools=source_tools, tools=(dumpbin, binskim, umdh), + ) + + def test_table_routes_every_driver_command_and_preserves_legacy_options(self) -> None: + repository = Path("selected-repo") + dumpbin, binskim, umdh = object(), object(), object() + cases = ( + ("build", ["-Arch", " x64, X64,x86 ", "-Config", "debug, RELEASE"], + (("x64", "x86"), ("Debug", "Release")), {}), + ("test", ["-Config", "Release", "-Corpus", "golden", "-TestShards", "7"], + (("x64",), ("Release",)), {"test_shards": 7, "corpus": Path("golden"), "run_nonce": "run-id"}), + ("compiler_analysis", [], (("x64",),), {}), + ("test_coverage", ["-Corpus", "golden"], + (("x64",),), {"test_shards": 4, "corpus": Path("golden"), "run_nonce": "run-id"}), + ("test_asan", ["-Arch", "x86,x64"], (("x86", "x64"),), {"test_shards": 4}), + ("test_ubsan", [], (("x64",),), {"test_shards": 4}), + ("fuzz", ["-FuzzSeconds", "91", "-FuzzTarget", "RenPy"], (), + {"run_nonce": "run-id", "seconds": 91, "targets": ("renpy",)}), + ("verify_source", ["-ExportDir", "source-evidence"], (), + {"export_dir": Path("source-evidence")}), + ("verify_arch", ["-Arch", "x86", "-ExportDir", "x86-evidence", + "-FuzzSeconds", "17"], (("x86",),), + {"corpus": None, "run_nonce": "run-id", "fuzz_seconds": 17, + "test_shards": 4, "warmup": 8, "iterations": 100, "windows": 3, + "tolerance_bytes": 0, "export_dir": Path("x86-evidence")}), + ("verify", ["-Arch", "all", "-Corpus", "golden", "-FuzzSeconds", "17", + "-LeakWarmup", "2", "-LeakIterations", "5", "-LeakWindows", "4", + "-LeakToleranceBytes", "9", "-ExportDir", "all-evidence"], + (("x86", "x64", "arm64"),), + {"corpus": Path("golden"), "run_nonce": "run-id", "fuzz_seconds": 17, + "test_shards": 4, "warmup": 2, "iterations": 5, "windows": 4, + "tolerance_bytes": 9, "export_dir": Path("all-evidence")}), + ) + command_names = { + "compiler_analysis": "compiler-analysis", "test_coverage": "test-coverage", + "test_asan": "test-asan", "test_ubsan": "test-ubsan", + "verify_source": "verify-source", "verify_arch": "verify-arch", + } + for method, options, positional, keywords in cases: + with self.subTest(command=method): + result = self.invoke([command_names.get(method, method), "-Repository", str(repository), *options]) + instance = FakeDriver.instances[-1] + self.assertEqual(instance.constructor, ( + (repository, "run-id", mock.ANY), + {"jobs": None, "prune_cas": False}, + )) + self.assertEqual(instance.calls, [(method, positional, keywords)]) + self.assertEqual(result.result, 0) + self.assertEqual(result.stdout.strip(), str(Path("out") / method)) + + restore = self.invoke([ + "restore", "-Repository", str(repository), "-Arch", "arm64,ALL", + "-RestoreFlavor", "ALL", + ]) + self.assertEqual(FakeDriver.instances[-1].calls, [ + ("restore", (("x86", "x64", "arm64"),), {"flavors": ("",)}), + ("restore", (("x86", "x64"),), {"flavors": ("asan",)}), + ]) + self.invoke(["restore", "-Repository", str(repository)]) + self.assertEqual(FakeDriver.instances[-1].calls, [ + ("restore", (("x64",),), {"flavors": ("",)}) + ]) + no_op = self.invoke([ + "restore", "-Repository", str(repository), "-Arch", "arm64", + "-RestoreFlavor", "asan", + ]) + self.assertEqual(FakeDriver.instances[-1].calls, []) + self.assertEqual(no_op.stdout, "") + + source = self.invoke(["source-checks", "-Repository", str(repository)]) + self.assertEqual(FakeDriver.instances[-1].calls, [ + ("source_checks", (("x64",), source.source_tools), {}) + ]) + source.source.assert_called_once() + + for command, method in (("audit-binaries", "audit"), ("package", "package")): + with self.subTest(command=command): + options = [command, "-Repository", str(repository), "-Jobs", "3"] + if command == "package": + options.extend(("-ExportDir", "package-evidence")) + result = self.invoke(options) + dumpbin, binskim, _umdh = result.tools + self.assertEqual(FakeDriver.instances[-1].calls, [ + (method, (("x64",),), { + "dumpbin": dumpbin, + "binskim": binskim, + **({"export_dir": Path("package-evidence")} if command == "package" else {}), + }) + ]) + + leak = self.invoke([ + "test-leaks", "-Repository", str(repository), "-LeakWarmup", "2", + "-LeakIterations", "6", "-LeakWindows", "5", "-LeakToleranceBytes", "10", + ]) + dumpbin, binskim, umdh = leak.tools + self.assertEqual(FakeDriver.instances[-1].calls, [ + ("test_leaks", (), { + "run_nonce": "run-id", "dumpbin": dumpbin, "binskim": binskim, "umdh": umdh, + "warmup": 2, "iterations": 6, "windows": 5, "tolerance_bytes": 10, + }) + ]) + + def test_doctor_and_clean_bypass_toolchain_and_driver(self) -> None: + with ( + mock.patch.object(main, "doctor_main", return_value=1) as doctor, + mock.patch.object(main, "discover_msvc_toolchain") as discover, + ): + self.assertEqual(main.main(["doctor"]), 1) + doctor.assert_called_once_with(()) + discover.assert_not_called() + + with mock.patch.object(main, "run_clean", return_value=0) as clean: + self.assertEqual(main.main([ + "clean", "-Repository", "repo", "-CleanMode", "stale-work" + ]), 0) + clean.assert_called_once_with(Path("repo"), "stale-work") + self.assertEqual(FakeDriver.instances, []) + + def test_clean_adapter_and_run_identifier_are_exact(self) -> None: + with mock.patch("core.clean.main", return_value=0) as clean: + self.assertEqual(main.run_clean(Path("repo"), "all"), 0) + clean.assert_called_once_with((str(Path("repo")), "--mode", "all")) + self.assertRegex(main._run_id(), re.compile(r"^\d{8}T\d{6}-\d+$")) + + def test_verify_prints_explicit_host_deferrals_before_outputs(self) -> None: + deferred = SimpleNamespace(gate="tests", architecture="arm64", reason="not runnable") + result = self.invoke(["verify", "-Arch", "arm64"], deferred=(deferred,)) + self.assertEqual( + result.stdout.splitlines(), + ["[DEFERRED] tests arm64: not runnable", str(Path("out") / "verify")], + ) + + async def fail(*_args: object, **_kwargs: object) -> tuple[Path, ...]: + raise RuntimeError("verify failed") + + stdout = StringIO() + with ( + mock.patch.dict( + main._COMMANDS, + {"verify": (main._COMMANDS["verify"][0], fail)}, + ), + mock.patch.object(main, "verify_route", return_value=SimpleNamespace(deferred=(deferred,))), + mock.patch.object(main, "discover_msvc_toolchain", return_value=object()), + mock.patch.object(main, "BuildDriver", FakeDriver), + mock.patch.object(main, "_run_id", return_value="run-id"), + redirect_stdout(stdout), self.assertRaisesRegex(RuntimeError, "verify failed"), + ): + main.main(["verify", "-Arch", "arm64"]) + self.assertEqual(stdout.getvalue(), "") + + def test_verify_prune_cas_flag_enables_driver_sweep(self) -> None: + result = self.invoke([ + "verify-arch", "-Arch", "x64", "-PruneCas", + ]) + + instance = FakeDriver.instances[-1] + self.assertEqual( + instance.constructor, + ((Path(__file__).parents[2], "run-id", mock.ANY), { + "jobs": None, + "prune_cas": True, + }), + ) + self.assertEqual(result.result, 0) + + def test_help_and_removed_skip_restore_contract(self) -> None: + for argv in ([], ["help"]): + with self.subTest(argv=argv), redirect_stdout(StringIO()) as stdout: + self.assertEqual(main.main(argv), 0) + self.assertIn("audit-binaries", stdout.getvalue()) + + for command in ("build", "restore"): + with ( + self.subTest(command=command), + redirect_stderr(StringIO()), + self.assertRaises(SystemExit) as raised, + ): + main.main([command, "-SkipDependencyRestore"]) + self.assertEqual(raised.exception.code, 2) + + def test_invalid_legacy_options_fail_before_discovery(self) -> None: + cases = ( + ["build", "-Arch", "mips"], + ["build", "-Arch", ""], ["build", "-Config", "Profile"], + ["restore", "-RestoreFlavor", "ubsan"], ["fuzz", "-FuzzSeconds", "0"], + ["fuzz", "-FuzzSeconds", "86401"], ["fuzz", "-FuzzTarget", "bad"], + ["fuzz", "-Arch", "x86"], ["test-leaks", "-Arch", "x86"], + ["test-leaks", "-LeakWarmup", "0"], ["test-leaks", "-LeakIterations", "x"], + ["test-leaks", "-LeakWindows", "2"], ["test-leaks", "-LeakWindows", "11"], + ["test-leaks", "-LeakToleranceBytes", "-1"], + ["test-coverage", "-CoverageThreshold", "99"], + ["test-coverage", "-CoverageThreshold", "100"], + ["test-coverage", "-CoverageThreshold", "100.0"], + ["test", "-TestShards", "0"], ["build", "-Jobs", "0"], + ["verify-arch", "-Arch", "all"], + ) + for argv in cases: + with ( + self.subTest(argv=argv), + mock.patch.object(main, "discover_msvc_toolchain") as discover, + redirect_stderr(StringIO()), self.assertRaises(SystemExit) as raised, + ): + main.main(argv) + self.assertEqual(raised.exception.code, 2) + discover.assert_not_called() + + def test_script_entry_point_uses_the_same_cli(self) -> None: + with ( + mock.patch.object(sys, "argv", [str(BUILD_ROOT / "main.py"), "doctor"]), + mock.patch("core.doctor.main", return_value=0) as doctor, + self.assertRaises(SystemExit) as raised, + ): + runpy.run_path(str(BUILD_ROOT / "main.py"), run_name="__main__") + self.assertEqual(raised.exception.code, 0) + doctor.assert_called_once_with(()) + + def test_root_powershell_entry_point_uses_frozen_project_environment(self) -> None: + script = (BUILD_ROOT.parent / "build.ps1").read_text(encoding="utf-8") + + self.assertIn("uv run --project $buildProject --frozen --no-sync", script) + self.assertIn("$PSScriptRoot 'build'", script) + self.assertNotIn("tools\\build", script) + self.assertIn("exit $LASTEXITCODE", script) + self.assertNotIn("build\\build.ps1", script) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_msbuild_contracts.py b/build/tests/test_msbuild_contracts.py new file mode 100644 index 0000000..5647de4 --- /dev/null +++ b/build/tests/test_msbuild_contracts.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from pathlib import Path +import unittest +import xml.etree.ElementTree as ET + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +MSBUILD = "{http://schemas.microsoft.com/developer/msbuild/2003}" + + +def _project(relative_path: str) -> ET.Element: + return ET.parse(REPOSITORY_ROOT / relative_path).getroot() + + +class MSBuildContractsTests(unittest.TestCase): + def test_direct_msbuild_fallback_stays_below_managed_work_root(self) -> None: + project = _project("build/ObserverProject.props") + + artifacts_root = project.find(f".//{MSBUILD}ArtifactsRoot") + self.assertIsNotNone(artifacts_root) + self.assertEqual( + artifacts_root.text, + "$(RepositoryRoot)out\\work\\manual-msbuild\\", + ) + + def test_analysis_reports_have_stable_unique_project_paths(self) -> None: + project = _project("build/ObserverProject.props") + + report_name = project.find(f".//{MSBUILD}ObserverAnalysisReportName") + self.assertIsNotNone(report_name) + self.assertEqual(report_name.text, "$(ProjectName)") + self.assertEqual( + report_name.get("Condition"), "'$(ObserverAnalysisReportName)' == ''" + ) + + report_logs = project.findall(f".//{MSBUILD}PREfastLog") + self.assertIn( + "$(ObserverAnalysisReportDirectory)\\$(PlatformMoniker)\\" + "$(ObserverAnalysisReportName).sarif", + (log.text for log in report_logs), + ) + + def test_fuzz_validation_allows_only_x64_fuzz_or_compile_analysis(self) -> None: + project = _project("build/ObserverFuzz.props") + validation = project.find( + f".//{MSBUILD}Target[@Name='ValidateFuzzConfiguration']/{MSBUILD}Error" + ) + + self.assertIsNotNone(validation) + self.assertEqual( + validation.get("Condition"), + "'$(ObserverCompileAnalysis)' != 'true' And " + "'$(Configuration)|$(Platform)' != 'Fuzz|x64'", + ) + + def test_leak_probe_is_shipping_x64_release_with_release_zlib(self) -> None: + project = _project("build/projects/leak-probe.vcxproj") + + runtimes = [ + node.text for node in project.findall(f".//{MSBUILD}RuntimeLibrary") + ] + dependencies = [ + node.text for node in project.findall(f".//{MSBUILD}AdditionalDependencies") + ] + validation = project.find( + f".//{MSBUILD}Target[@Name='ValidateLeakProbeConfiguration']/{MSBUILD}Error" + ) + + self.assertEqual(runtimes, ["MultiThreaded"]) + self.assertEqual(dependencies, ["zs.lib;%(AdditionalDependencies)"]) + self.assertIsNotNone(validation) + self.assertEqual( + validation.get("Condition"), + "'$(Configuration)|$(Platform)' != 'Release|x64'", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_native_graph.py b/build/tests/test_native_graph.py new file mode 100644 index 0000000..6a24a2a --- /dev/null +++ b/build/tests/test_native_graph.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +import sys +import tempfile +import unittest +import xml.etree.ElementTree as ET + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from graphs.native import native_dependency_discovery_slice, native_graph # noqa: E402 + + +@dataclass(frozen=True) +class FakeToolchain: + msbuild: Path + pwsh: Path + vcpkg_root: Path + environment: tuple[tuple[str, str], ...] + identity: dict[str, str] + + +class NativeGraphTests(unittest.TestCase): + def test_parallel_msvc_builds_do_not_depend_on_shared_compiler_pdb_server( + self, + ) -> None: + root = ET.parse( + BUILD_ROOT / "ObserverProject.props" + ).getroot() + debug_information = root.find( + ".//{http://schemas.microsoft.com/developer/msbuild/2003}DebugInformationFormat" + ) + + self.assertIsNotNone(debug_information) + self.assertEqual(debug_information.text, "OldStyle") + + def repository(self, root: Path) -> Path: + project = """ + + + + + + {definition} + +""" + files = { + "src/renpy.cpp": '#include "renpy.h"\n#include \n', + "src/renpy.h": "#pragma once\n", + "src/rpgmaker.cpp": '#include "rpgmaker.h"\n', + "src/rpgmaker.h": "#pragma once\n", + "src/zanzarah.cpp": '#include "zanzarah.h"\n', + "src/zanzarah.h": "#pragma once\n", + "src/tests.cpp": ( + '#include "tests.h"\n#include \n' + ), + "src/tests.h": "#pragma once\n", + "src/leak-probe.cpp": '#include "leak-probe.h"\n#include \n', + "src/leak-probe.h": "#pragma once\n", + "src/renpy.def": "EXPORTS\n OpenStorage\n", + "build/ObserverProjectConfigurations.props": "\n", + "build/ObserverConfiguration.props": "\n", + "build/ObserverProject.props": "\n", + "vcpkg.json": '{"dependencies":["zlib"]}\n', + } + for architecture in ("x64", "arm64"): + files[f"build/vcpkg/triplets/observer-{architecture}-windows-static.cmake"] = ( + "set(VCPKG_LIBRARY_LINKAGE static)\n" + ) + for name in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe"): + definition = ( + "" + "$(RepositoryRoot)src\\renpy.def" + "" + if name == "renpy" + else "" + ) + files[f"build/projects/{name}.vcxproj"] = project.format( + name=name, definition=definition + ) + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def toolchain(self, root: Path) -> FakeToolchain: + tools = root / "fake tools" + tools.mkdir() + vcpkg_root = tools / "vcpkg" + vcpkg_root.mkdir() + (vcpkg_root / "vcpkg.exe").touch() + msbuild = tools / "MSBuild.exe" + pwsh = tools / "pwsh.exe" + msbuild.touch() + pwsh.touch() + return FakeToolchain( + msbuild=msbuild, + pwsh=pwsh, + vcpkg_root=vcpkg_root, + environment=(("PATH", str(tools)),), + identity={"msbuild": "17.14", "msvc": "14.44"}, + ) + + def staged_graphs(self, repository: Path, toolchain: FakeToolchain, **options): + discovery = native_dependency_discovery_slice( + repository, + toolchain, + jobs=options.get("jobs", 2), + architectures=options.get("architectures", ("x64",)), + configurations=options.get("configurations", ("Debug",)), + include_leak_probe=options.get("include_leak_probe", False), + ) + manifests = {} + for target in discovery.targets: + project = next( + name + for name in ("renpy", "rpgmaker", "zanzarah", "tests", "leak-probe") + if target.endswith(f"-{name}") + ) + source = repository / f"src/{project}.cpp" + manifests[target] = json.dumps( + { + "Data": { + "Source": str(source.resolve()), + "Includes": [str((repository / f"src/{project}.h").resolve())], + } + } + ).encode() + return discovery, native_graph( + repository, toolchain, discovery=discovery, manifests=manifests, **options + ) + + def test_projects_restore_and_test_shards_form_a_fine_grained_dag(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.staged_graphs( + repository, + self.toolchain(root), + architectures=("x64", "arm64"), + configurations=("Debug", "Release"), + runnable_architectures=("x64",), + test_shards=2, + jobs=8, + ) + + names = tuple(node.name for node in graph.nodes) + self.assertEqual(names.count("restore-vcpkg-x64"), 1) + self.assertEqual(names.count("restore-vcpkg-arm64"), 1) + self.assertIn("build-renpy-x64-debug", names) + self.assertIn("build-tests-arm64-release", names) + self.assertFalse(any("leak-probe" in name for name in names)) + self.assertEqual( + tuple(name for name in names if name.startswith("test-shard-")), + ( + "test-shard-x64-debug-0", + "test-shard-x64-debug-1", + "test-shard-x64-release-0", + "test-shard-x64-release-1", + ), + ) + self.assertEqual(graph.pools, {"restore": 1, "slot": 8}) + + restore = graph.node("restore-vcpkg-x64") + builds = tuple( + graph.node(f"build-{project}-x64-debug") + for project in ("renpy", "rpgmaker", "zanzarah", "tests") + ) + expected = tuple( + next(name for name in discovery.targets if f"-debug-{project}-" in name) + for project in ("renpy", "rpgmaker", "zanzarah", "tests") + ) + self.assertEqual(tuple(node.inputs[0] for node in builds), expected) + self.assertTrue(all(node.pool == "slot" for node in builds)) + for node in builds: + script = node.command.stdin.decode("utf-8") + self.assertIn("'/m:1'", script) + self.assertIn("'/p:BuildProjectReferences=false'", script) + self.assertIn('"/p:OutDir=$outDir\\"', script) + self.assertIn('"/p:IntDir=$buildDir\\"', script) + for node in builds: + self.assertIn("'/p:VcpkgInstalledDir=", node.command.stdin.decode()) + shard = graph.node("test-shard-x64-debug-0") + self.assertEqual( + shard.inputs, + tuple(node.name for node in builds), + ) + shard_script = shard.command.stdin.decode("utf-8") + for artifact in ("renpy.so", "rpgmaker.so", "zanzarah.so", "tests.exe"): + self.assertIn(artifact, shard_script) + self.assertIn("'--shard-count'", shard_script) + self.assertIn("'2'", shard_script) + self.assertIn("'--shard-index'", shard_script) + self.assertIn("'0'", shard_script) + self.assertIn("'JUnit::out=", shard_script) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in shard.results), + (( + "reports/tests/x64/debug/unit/shard-0.xml", + "test", "application/xml", "tests.xml", + ),), + ) + self.assertTrue(all( + not node.results + for node in graph.nodes + if node.name.startswith(("restore-", "discover-", "build-")) + )) + + def test_leak_probe_is_an_explicit_release_only_request(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + discovery, graph = self.staged_graphs( + repository, + self.toolchain(root), + configurations=("Debug", "Release"), + runnable_architectures=(), + include_leak_probe=True, + ) + + names = tuple(node.name for node in graph.nodes) + self.assertIn("build-leak-probe-x64-release", names) + self.assertNotIn("build-leak-probe-x64-debug", names) + leak_probe = graph.node("build-leak-probe-x64-release") + self.assertEqual(len(leak_probe.inputs), 1) + self.assertIn("-release-leak-probe-", leak_probe.inputs[0]) + self.assertTrue(any("-release-leak-probe-" in name for name in discovery.targets)) + + def test_external_corpus_is_test_only_and_nonce_invalidated(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + corpus = root / "corpus" + other_corpus = root / "other-corpus" + corpus.mkdir() + other_corpus.mkdir() + options = dict( + configurations=("Debug",), runnable_architectures=("x64",), test_shards=1 + ) + _discovery, baseline = self.staged_graphs(repository, toolchain, **options) + _discovery, no_corpus = self.staged_graphs( + repository, toolchain, corpus=None, run_nonce="ignored", **options + ) + _discovery, first = self.staged_graphs( + repository, toolchain, corpus=corpus, run_nonce="run-one", **options + ) + (corpus / "large-external-file.bin").write_bytes(b"not signed") + _discovery, content_changed = self.staged_graphs( + repository, toolchain, corpus=corpus, run_nonce="run-one", **options + ) + _discovery, rerun = self.staged_graphs( + repository, toolchain, corpus=corpus, run_nonce="run-two", **options + ) + _discovery, moved = self.staged_graphs( + repository, toolchain, corpus=other_corpus, run_nonce="run-one", **options + ) + _discovery, build_only = self.staged_graphs( + repository, toolchain, corpus=corpus, run_nonce="build-only", + configurations=("Release",), runnable_architectures=(), + ) + + shard_name = "test-shard-x64-debug-0" + corpus_name = "corpus-shard-x64-debug-0" + self.assertEqual(baseline.node(shard_name).uid, no_corpus.node(shard_name).uid) + self.assertEqual(baseline.node(shard_name).uid, first.node(shard_name).uid) + self.assertFalse(any(node.name.startswith("corpus-shard-") for node in baseline.nodes)) + self.assertEqual(first.node(corpus_name).uid, content_changed.node(corpus_name).uid) + self.assertNotEqual(first.node(corpus_name).uid, rerun.node(corpus_name).uid) + self.assertNotEqual(first.node(corpus_name).uid, moved.node(corpus_name).uid) + self.assertEqual(first.node(corpus_name).inputs, first.node(shard_name).inputs) + self.assertEqual(dict(first.node(corpus_name).command.env)["OBSERVER_TEST_CORPUS"], str(corpus.resolve())) + self.assertNotIn("OBSERVER_TEST_CORPUS", dict(first.node(shard_name).command.env)) + self.assertIn("'[compatibility]'", first.node(corpus_name).command.stdin.decode()) + self.assertEqual( + tuple((item.id, item.relative_path) for item in first.node(corpus_name).results), + (("reports/tests/x64/debug/corpus/shard-0.xml", "tests.xml"),), + ) + self.assertTrue(all( + "OBSERVER_TEST_CORPUS" not in dict(node.command.env) + for node in first.nodes if not node.name.startswith("corpus-shard-") + )) + self.assertFalse(any( + node.name.startswith(("test-shard-", "corpus-")) for node in build_only.nodes + )) + + def test_project_content_invalidates_only_its_build_and_consuming_shards(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + options = dict( + architectures=("x64",), + configurations=("Debug",), + runnable_architectures=("x64",), + test_shards=2, + ) + _discovery, before = self.staged_graphs(repository, toolchain, **options) + (repository / "src/renpy.cpp").write_text( + '#include "renpy.h"\n#include \n// changed\n', encoding="utf-8" + ) + _discovery, after = self.staged_graphs(repository, toolchain, **options) + (repository / "src/rpgmaker.h").write_text( + "#pragma once\n#include \n", encoding="utf-8" + ) + _discovery, unknown_package = self.staged_graphs( + repository, toolchain, **options + ) + + for name in ( + "restore-vcpkg-x64", + "build-rpgmaker-x64-debug", + "build-zanzarah-x64-debug", + "build-tests-x64-debug", + ): + self.assertEqual(before.node(name).uid, after.node(name).uid) + self.assertNotEqual( + before.node("build-renpy-x64-debug").uid, + after.node("build-renpy-x64-debug").uid, + ) + for index in range(2): + name = f"test-shard-x64-debug-{index}" + self.assertNotEqual(before.node(name).uid, after.node(name).uid) + rpgmaker = "build-rpgmaker-x64-debug" + self.assertEqual(len(unknown_package.node(rpgmaker).inputs), 1) + self.assertNotEqual(after.node(rpgmaker).uid, unknown_package.node(rpgmaker).uid) + + def test_invalid_axes_are_rejected_before_graph_construction(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + with self.assertRaisesRegex(ValueError, "unsupported architecture"): + native_graph( + repository, + toolchain, + discovery=None, + manifests={}, + architectures=("mips",), + runnable_architectures=(), + ) + with self.assertRaisesRegex(ValueError, "unsupported configuration"): + native_graph( + repository, + toolchain, + discovery=None, + manifests={}, + configurations=("RelWithDebInfo",), + runnable_architectures=(), + ) + with self.assertRaisesRegex(ValueError, "test_shards"): + native_graph( + repository, + toolchain, + discovery=None, + manifests={}, + runnable_architectures=("x64",), + test_shards=0, + ) + with self.assertRaisesRegex(ValueError, "unsupported configuration"): + native_dependency_discovery_slice( + repository, toolchain, configurations=("RelWithDebInfo",) + ) + with self.assertRaisesRegex(ValueError, "unsupported architecture"): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=("mips",), + ) + with self.assertRaisesRegex(ValueError, "dependency discovery is required"): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=(), + ) + corpus = root / "corpus" + corpus.mkdir() + with self.assertRaisesRegex(ValueError, "run nonce"): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=("x64",), corpus=corpus, run_nonce="", + ) + with self.assertRaises(FileNotFoundError): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=("x64",), corpus=root / "missing", run_nonce="run", + ) + file_corpus = root / "corpus.bin" + file_corpus.touch() + with self.assertRaises(NotADirectoryError): + native_graph( + repository, toolchain, discovery=None, manifests={}, + runnable_architectures=("x64",), corpus=file_corpus, run_nonce="run", + ) + + def test_native_build_requires_every_compiler_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + toolchain = self.toolchain(root) + discovery = native_dependency_discovery_slice(repository, toolchain) + + with self.assertRaisesRegex(ValueError, "missing dependency manifest"): + native_graph( + repository, toolchain, discovery=discovery, manifests={}, + runnable_architectures=(), + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_node.py b/build/tests/test_node.py new file mode 100644 index 0000000..0573e65 --- /dev/null +++ b/build/tests/test_node.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import tempfile +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Result # noqa: E402 +from core.node import NodeFactory # noqa: E402 +from core.render import TemplateRenderer # noqa: E402 + + +class NodeFactoryTests(unittest.TestCase): + def test_declared_results_flow_from_factory_to_node(self) -> None: + renderer = TemplateRenderer(BUILD_ROOT / "templates") + report = Result( + "analysis/example/json", + "report", + "application/json", + "reports/example.json", + ) + with tempfile.TemporaryDirectory() as temporary: + factory = NodeFactory(renderer, Path(temporary), {"tool": "1"}) + current = factory.make( + "argv.json", + "example", + "slot", + {"argv": (str(Path(sys.executable).resolve()), "--version")}, + files={}, + results=(report,), + config={"action": "probe"}, + ) + + self.assertEqual(current.results, (report,)) + + def test_runtime_environment_and_cwd_are_signed(self) -> None: + renderer = TemplateRenderer(BUILD_ROOT / "templates") + with tempfile.TemporaryDirectory() as temporary: + first = Path(temporary) / "first" + second = Path(temporary) / "second" + first.mkdir() + second.mkdir() + + def create(cwd: Path, value: str): + factory = NodeFactory(renderer, cwd, {"tool": "1"}, (("SETTING", value),)) + return factory.make( + "argv.json", + "example", + "slot", + {"argv": (str(Path(sys.executable).resolve()), "--version")}, + files={}, + config={"action": "probe"}, + ) + + baseline = create(first, "one") + changed_environment = create(first, "two") + changed_cwd = create(second, "one") + + self.assertNotEqual(baseline.uid, changed_environment.uid) + self.assertNotEqual(baseline.uid, changed_cwd.uid) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_package_graph.py b/build/tests/test_package_graph.py new file mode 100644 index 0000000..fd94f74 --- /dev/null +++ b/build/tests/test_package_graph.py @@ -0,0 +1,508 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import runpy +import sys +import tempfile +import unittest +from unittest import mock +import zipfile + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Graph, Node # noqa: E402 +from core.package import PackageError, main as package_main # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from graphs.package import package_graph, package_outputs # noqa: E402 + + +MODULES = ("renpy", "rpgmaker", "zanzarah") +LICENSES = { + "renpy": ("Observer.txt", "rpatool.txt", "serde-pickle.txt", "zlib.txt"), + "rpgmaker": ("Observer.txt", "rgssad.txt"), + "zanzarah": ("Observer.txt", "zanzapak.txt"), +} + + +def node(name: str, seed: str | None = None, *, inputs: tuple[str, ...] = ()) -> Node: + return Node(name, hashlib.md5((seed or name).encode()).hexdigest(), "build", Command(("build",)), inputs) + + +class PackageGraphTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + for module, licenses in LICENSES.items(): + registration = root / f"src/modules/{module}/observer_user.ini" + registration.parent.mkdir(parents=True, exist_ok=True) + registration.write_text(f"[{module}]\n", encoding="utf-8") + for name in licenses: + path = root / "licenses" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"license:{name}\n", encoding="utf-8") + (root / "LICENSE.txt").write_text("project license\n", encoding="utf-8") + return root + + def fixture( + self, root: Path, architectures: tuple[str, ...] = ("x64", "arm64") + ) -> tuple[Path, Graph]: + repository = self.repository(root / "repo") + producers = tuple( + node(f"build-{module}-{architecture}-release") + for architecture in architectures + for module in MODULES + ) + audit_gates = tuple( + node(f"audit-{kind}-{architecture}-{module}", inputs=(producer.name,)) + for producer, (architecture, module) in zip( + producers, + ((architecture, module) for architecture in architectures for module in MODULES), + strict=True, + ) + for kind in ("pe", "binskim") + ) + tests = tuple(node(f"build-tests-{architecture}-release") for architecture in architectures) + upstream = Graph( + (*producers, *tests, *audit_gates), + tuple(item.name for item in producers), + {"build": 4}, + ) + return repository, upstream + + def test_package_units_fan_out_and_only_inherent_aggregates_fan_in(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream = self.fixture(Path(temporary)) + graph = package_graph( + repository, upstream, architectures=("x64", "arm64"), jobs=7 + ) + paths = BuildPaths(repository) + outputs = package_outputs(repository, graph) + with self.assertRaisesRegex(ValueError, "no package outputs"): + package_outputs(repository, upstream) + empty_manifest = Graph( + (node("package-manifest"),), ("package-manifest",), {"build": 1} + ) + with self.assertRaisesRegex(ValueError, "no package outputs"): + package_outputs(repository, empty_manifest) + + self.assertEqual(graph.pools, {"build": 4, "package": 7}) + self.assertEqual(len(graph.nodes) - len(upstream.nodes), 29) + self.assertEqual(graph.targets, ("package-manifest",)) + for architecture in ("arm64", "x64"): + for module in MODULES: + suffix = f"{architecture}-{module}" + producer = graph.node(f"build-{module}-{architecture}-release") + stage = graph.node(f"package-stage-{suffix}") + symbols = graph.node(f"package-symbol-stage-{suffix}") + archive = graph.node(f"package-archive-{suffix}") + validation = graph.node(f"package-validate-{suffix}") + gates = ( + f"audit-pe-{architecture}-{module}", + f"audit-binskim-{architecture}-{module}", + ) + self.assertEqual(stage.inputs, (producer.name, *gates)) + self.assertEqual(symbols.inputs, (producer.name, *gates)) + self.assertEqual(archive.inputs, (stage.name, *gates)) + self.assertEqual(validation.inputs, (archive.name, stage.name)) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in validation.results), + (( + f"reports/package/{architecture}/{module}/validation.json", + "package-validation", "application/json", "validation.json", + ),), + ) + self.assertEqual(archive.results, ()) + self.assertEqual( + validation.command.argv[3:], + ("validate-module", architecture, module, + str(paths.cas(archive.uid).output / f"{module}-{architecture}-dll.zip"), + str(paths.cas(stage.uid).output)), + ) + self.assertEqual(stage.command.argv[:3], (sys.executable, "-m", "core.package")) + producer_output = paths.cas(producer.uid).output + self.assertIn(str(producer_output / f"{module}.so"), stage.command.argv) + self.assertIn(str(producer_output / f"{module}.pdb"), symbols.command.argv) + self.assertIn(str(paths.cas(stage.uid).output), archive.command.argv) + + for architecture in ("arm64", "x64"): + combined = graph.node(f"package-symbols-{architecture}") + validation = graph.node(f"package-symbols-validate-{architecture}") + self.assertEqual( + combined.inputs, + tuple(f"package-symbol-stage-{architecture}-{module}" for module in MODULES), + ) + self.assertEqual( + validation.inputs, + (combined.name, *combined.inputs), + ) + self.assertEqual( + tuple((item.id, item.relative_path) for item in validation.results), + ((f"reports/package/{architecture}/symbols/validation.json", "validation.json"),), + ) + aggregate = graph.node("package-manifest") + self.assertEqual(len(aggregate.inputs), 8) + self.assertTrue(all(name.startswith("package-validate-") or name.startswith("package-symbols-validate-") + for name in aggregate.inputs)) + expected_names = { + *(f"{module}-{architecture}-dll.zip" + for architecture in ("arm64", "x64") for module in MODULES), + *(f"observer-modules-{architecture}-pdb.zip" + for architecture in ("arm64", "x64")), + } + self.assertEqual( + {(item.id, item.kind, item.media_type, item.relative_path) + for item in aggregate.results}, + { + (f"packages/{name.split('-')[1] if '-modules-' not in name else name.split('-')[2]}/{name}", + "package", "application/zip", name) + for name in expected_names + } | {("packages/packages.json", "package-manifest", "application/json", "packages.json")}, + ) + self.assertEqual( + {Path(argument).name for argument in aggregate.command.argv[4:]}, + expected_names, + ) + self.assertTrue(all(node.pool == "package" for node in graph.nodes[len(upstream.nodes) :])) + self.assertEqual( + outputs, + tuple( + paths.cas(aggregate.uid).output / archive_name + for architecture in ("arm64", "x64") + for archive_name in ( + *(f"{module}-{architecture}-dll.zip" for module in MODULES), + f"observer-modules-{architecture}-pdb.zip", + ) + ), + ) + + def test_repository_metadata_invalidates_only_consuming_package_partition(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream = self.fixture(Path(temporary), ("x64",)) + before = package_graph(repository, upstream, architectures=("x64",)) + (repository / "src/modules/renpy/observer_user.ini").write_text("changed\n", encoding="utf-8") + after = package_graph(repository, upstream, architectures=("x64",)) + + changed = {current.name for current in before.nodes if current.uid != after.node(current.name).uid} + self.assertEqual( + changed, + { + "package-stage-x64-renpy", "package-archive-x64-renpy", + "package-validate-x64-renpy", "package-manifest", + }, + ) + + def test_package_smokes_run_independently_against_exact_archives(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream = self.fixture(Path(temporary), ("x64",)) + test_producer = upstream.node("build-tests-x64-release") + paths = BuildPaths(repository) + executable = paths.cas(test_producer.uid).output / "tests.exe" + graph = package_graph( + repository, + upstream, + architectures=("x64",), + smoke_architectures=("x64",), + ) + + smokes = tuple(graph.node(f"package-smoke-x64-{module}") for module in MODULES) + for smoke, module in zip(smokes, MODULES, strict=True): + self.assertEqual( + smoke.inputs, + (f"package-validate-x64-{module}", test_producer.name), + ) + self.assertEqual(smoke.command.argv[3:6], ("smoke", "x64", module)) + archive = graph.node(f"package-archive-x64-{module}") + self.assertEqual( + Path(smoke.command.argv[6]), + paths.cas(archive.uid).output / f"{module}-x64-dll.zip", + ) + self.assertIn(str(executable), smoke.command.argv) + manifest_inputs = set(graph.node("package-manifest").inputs) + self.assertEqual( + manifest_inputs, + {smoke.name for smoke in smokes} + | {f"package-validate-x64-{module}" for module in MODULES} + | {"package-symbols-validate-x64"}, + ) + + def test_invalid_axes_lineage_and_pool_contracts_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream = self.fixture(Path(temporary), ("x64",)) + + for jobs in (0, True, "four"): + with self.subTest(jobs=jobs), self.assertRaisesRegex(ValueError, "positive integer"): + package_graph( + repository, upstream, architectures=("x64",), jobs=jobs # type: ignore[arg-type] + ) + for invalid in ((), ("x64", "x64"), ("mips",)): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "architectures"): + package_graph(repository, upstream, architectures=invalid) + + removed = { + "build-renpy-x64-release", + "audit-pe-x64-renpy", + "audit-binskim-x64-renpy", + } + missing_build = Graph( + tuple(item for item in upstream.nodes if item.name not in removed), + tuple(name for name in upstream.targets if name not in removed), + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "unknown node"): + package_graph(repository, missing_build, architectures=("x64",)) + + missing_gate = Graph( + tuple(node for node in upstream.nodes if node.name != "audit-pe-x64-renpy"), + upstream.targets, + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "audit gates"): + package_graph(repository, missing_gate, architectures=("x64",)) + pe_gate = upstream.node("audit-pe-x64-renpy") + producer = upstream.node("build-renpy-x64-release") + proof = node("audit-proof-x64-renpy", inputs=(producer.name,)) + indirect_gate = Node(pe_gate.name, pe_gate.uid, pe_gate.pool, pe_gate.command, (proof.name,)) + indirect = Graph( + (*tuple(item for item in upstream.nodes if item != pe_gate), proof, indirect_gate), + upstream.targets, + upstream.pools, + ) + self.assertEqual( + package_graph(repository, indirect, architectures=("x64",)).targets, + ("package-manifest",), + ) + unrelated_gate = Node( + pe_gate.name, + pe_gate.uid, + pe_gate.pool, + pe_gate.command, + (upstream.node("build-rpgmaker-x64-release").name,), + ) + unrelated = Graph( + (*tuple(item for item in upstream.nodes if item != pe_gate), unrelated_gate), + upstream.targets, + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "do not consume producer"): + package_graph(repository, unrelated, architectures=("x64",)) + + matching = Graph(upstream.nodes, upstream.targets, {"build": 4, "package": 4}) + self.assertEqual( + package_graph(repository, matching, architectures=("x64",)).pools["package"], 4 + ) + conflicting = Graph(upstream.nodes, upstream.targets, {"build": 4, "package": 1}) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + package_graph(repository, conflicting, architectures=("x64",)) + + def test_invalid_package_smoke_axes_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository, upstream = self.fixture(Path(temporary), ("x64",)) + for smokes in (("x64", "x64"), ("x86",)): + with self.subTest(smokes=smokes), self.assertRaisesRegex(ValueError, "smoke architectures"): + package_graph( + repository, + upstream, + architectures=("x64",), + smoke_architectures=smokes, + ) + + missing_test = Graph( + tuple(item for item in upstream.nodes if item.name != "build-tests-x64-release"), + upstream.targets, + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "unknown node"): + package_graph( + repository, + missing_test, + architectures=("x64",), + smoke_architectures=("x64",), + ) + + +class PackageActionTests(unittest.TestCase): + def repository(self, root: Path) -> Path: + return PackageGraphTests().repository(root) + + @staticmethod + def invoke(output: Path, *arguments: str) -> int: + output.mkdir() + with mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(output)}, clear=False): + return package_main(arguments) + + def test_module_stage_archive_and_aggregate_are_reproducible(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + binary = root / "renpy.so" + binary.write_bytes(b"module") + stage = root / "stage" + self.assertEqual(0, self.invoke(stage, "stage-module", "x64", "renpy", str(binary), str(repository))) + + payload = stage / "payload" + expected = { + "renpy.so", "observer_user.ini", "docs/license.txt", + *(f"docs/thirdparty/{name}" for name in LICENSES["renpy"]), + } + self.assertEqual( + expected, + { + path.relative_to(payload).as_posix() + for path in payload.rglob("*") + if path.is_file() + }, + ) + document = json.loads((stage / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual( + ("x64", "module", "renpy"), + (document["architecture"], document["kind"], document["module"]), + ) + self.assertEqual(sorted(expected), [entry["name"] for entry in document["entries"]]) + + archive_one, archive_two = root / "archive-one", root / "archive-two" + self.invoke(archive_one, "archive-module", "x64", "renpy", str(stage)) + self.invoke(archive_two, "archive-module", "x64", "renpy", str(stage)) + first = archive_one / "renpy-x64-dll.zip" + second = archive_two / "renpy-x64-dll.zip" + self.assertEqual(first.read_bytes(), second.read_bytes()) + with zipfile.ZipFile(first) as archive: + self.assertEqual(sorted(expected), archive.namelist()) + self.assertTrue(all(item.date_time == (1980, 1, 1, 0, 0, 0) for item in archive.infolist())) + self.assertEqual(b"module", archive.read("renpy.so")) + + validation = root / "validation" + self.assertEqual( + 0, + self.invoke(validation, "validate-module", "x64", "renpy", str(first), str(stage)), + ) + proof = json.loads((validation / "validation.json").read_text(encoding="utf-8")) + self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest(), proof["sha256"]) + self.assertEqual(sorted(expected), [entry["name"] for entry in proof["entries"]]) + + tampered = root / "tampered.zip" + with zipfile.ZipFile(first) as source, zipfile.ZipFile(tampered, "w") as target: + for name in source.namelist(): + target.writestr(name, b"changed" if name == "renpy.so" else source.read(name)) + with self.assertRaisesRegex(PackageError, "exact stage manifests"): + self.invoke(root / "tampered-validation", "validate-module", "x64", "renpy", str(tampered), str(stage)) + duplicate = root / "duplicate.zip" + with self.assertWarns(UserWarning), zipfile.ZipFile(duplicate, "w") as archive: + archive.writestr("renpy.so", b"first") + archive.writestr("renpy.so", b"second") + for index, invalid in enumerate((duplicate, root / "invalid.zip")): + if not invalid.exists(): + invalid.write_bytes(b"not a zip") + with self.subTest(invalid=invalid), self.assertRaisesRegex(PackageError, "exact stage manifests"): + self.invoke(root / f"invalid-validation-{index}", "validate-module", + "x64", "renpy", str(invalid), str(stage)) + + aggregate = root / "aggregate" + self.invoke(aggregate, "aggregate", str(first)) + self.assertEqual((aggregate / first.name).read_bytes(), first.read_bytes()) + packages = json.loads((aggregate / "packages.json").read_text(encoding="utf-8")) + self.assertEqual("renpy-x64-dll.zip", packages[0]["name"]) + self.assertEqual(hashlib.sha256(first.read_bytes()).hexdigest(), packages[0]["sha256"]) + + def test_symbol_stages_fan_into_one_deterministic_architecture_archive(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + stages = [] + for module in MODULES: + symbol = root / f"{module}.pdb" + symbol.write_bytes(module.encode()) + stage = root / f"stage-{module}" + self.invoke(stage, "stage-symbol", "arm64", module, str(symbol)) + stages.append(stage) + output = root / "symbols" + self.invoke(output, "archive-symbols", "arm64", *(str(stage) for stage in stages)) + with zipfile.ZipFile(output / "observer-modules-arm64-pdb.zip") as archive: + self.assertEqual([f"{module}.pdb" for module in MODULES], archive.namelist()) + self.assertEqual(b"zanzarah", archive.read("zanzarah.pdb")) + validation = root / "symbols-validation" + archive = output / "observer-modules-arm64-pdb.zip" + self.assertEqual( + 0, + self.invoke(validation, "validate-symbols", "arm64", str(archive), *(str(stage) for stage in stages)), + ) + self.assertEqual( + hashlib.sha256(archive.read_bytes()).hexdigest(), + json.loads((validation / "validation.json").read_text())["sha256"], + ) + + def test_smoke_extracts_and_runs_the_exact_archived_module(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = root / "renpy-x64-dll.zip" + with zipfile.ZipFile(archive, "w") as package: + package.writestr("renpy.so", b"exact packaged module") + tests = root / "tests.exe" + tests.write_bytes(b"test runner") + output = root / "smoke" + + with mock.patch("core.package.subprocess.run") as run: + self.invoke(output, "smoke", "x64", "renpy", str(archive), str(tests)) + + module = output / "renpy.so" + self.assertEqual(module.read_bytes(), b"exact packaged module") + run.assert_called_once() + arguments = run.call_args.args[0] + self.assertEqual(arguments[:2], [str(tests), "[package-smoke]"]) + self.assertEqual(run.call_args.kwargs["cwd"], output) + self.assertEqual(run.call_args.kwargs["env"]["OBSERVER_PACKAGE_MODULE"], str(module)) + self.assertEqual(run.call_args.kwargs["env"]["OBSERVER_PACKAGE_FORMAT"], "renpy") + + missing = root / "missing.zip" + with zipfile.ZipFile(missing, "w") as package: + package.writestr("other.so", b"wrong module") + with self.assertRaisesRegex(PackageError, "archive has no renpy.so"): + self.invoke(root / "missing-smoke", "smoke", "x64", "renpy", str(missing), str(tests)) + + def test_manifest_mismatch_duplicate_archive_and_missing_output_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + binary = root / "renpy.so" + binary.write_bytes(b"module") + stage = root / "stage" + self.invoke(stage, "stage-module", "x86", "renpy", str(binary), str(repository)) + (stage / "payload/extra.obj").write_bytes(b"unexpected") + with self.assertRaisesRegex(PackageError, "manifest does not match"): + self.invoke(root / "bad-archive", "archive-module", "x86", "renpy", str(stage)) + + archive = root / "same.zip" + archive.write_bytes(b"same") + with self.assertRaisesRegex(PackageError, "duplicate archive name"): + self.invoke(root / "bad-aggregate", "aggregate", str(archive), str(archive)) + + with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(PackageError, "OBSERVER_OUT_DIR"): + package_main(("aggregate", "missing.zip")) + + def test_module_validation_and_module_entrypoint_dispatch(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = self.repository(root / "repo") + symbol = root / "renpy.pdb" + symbol.write_bytes(b"symbols") + stage = root / "stage" + self.invoke(stage, "stage-symbol", "x64", "renpy", str(symbol)) + with self.assertRaisesRegex(PackageError, "expected module set"): + self.invoke(root / "bad-symbols", "archive-symbols", "x64", str(stage)) + + output = root / "entrypoint" + output.mkdir() + with ( + mock.patch.dict(os.environ, {"OBSERVER_OUT_DIR": str(output)}, clear=False), + mock.patch.object(sys, "argv", ["package.py", "aggregate", str(symbol)]), + self.assertWarnsRegex(RuntimeWarning, "core.package"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.package", run_name="__main__") + self.assertEqual(0, raised.exception.code) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_paths.py b/build/tests/test_paths.py new file mode 100644 index 0000000..18ad658 --- /dev/null +++ b/build/tests/test_paths.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from core.paths import BuildPaths, PathSafetyError + + +UID = "0123456789abcdef0123456789abcdef" + + +class BuildPathsTests(unittest.TestCase): + def test_prepare_creates_only_cas_and_work_at_output_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + paths.prepare() + + self.assertEqual(paths.output_root, repository / "out") + self.assertEqual( + {child.name for child in paths.output_root.iterdir()}, + {"cas", "work"}, + ) + self.assertTrue(paths.cas_root.is_dir()) + self.assertTrue(paths.work_root.is_dir()) + self.assertTrue(paths.locks_root.is_dir()) + + def test_cas_paths_use_only_uid_for_entry_output_touch_and_log(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + entry = paths.cas(UID) + + self.assertEqual( + entry.entry, + repository / "out" / "cas" / UID, + ) + self.assertEqual(entry.output, entry.entry / "out") + self.assertEqual(entry.touch, entry.entry / "touch") + self.assertEqual(entry.log, entry.entry / "log.txt") + + def test_run_work_and_lock_paths_are_confined_under_work(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + self.assertEqual( + paths.run_work("20260801-abcdef"), + repository / "out" / "work" / "20260801-abcdef", + ) + self.assertEqual( + paths.lock(UID), + repository / "out" / "work" / ".locks" / f"{UID}.lock", + ) + self.assertEqual( + paths.coordination_lock(), + repository / "out" / "work" / ".locks" / "coordination.lock", + ) + self.assertEqual( + paths.lease("20260801-abcdef"), + repository / "out" / "work" / ".locks" / "run-20260801-abcdef.lease", + ) + + def test_invalid_uid_and_run_identifier_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + for invalid_uid in ("", "ABCDEF" * 5 + "AB", "../escape", "0" * 31): + with self.subTest(uid=invalid_uid): + with self.assertRaises(PathSafetyError): + paths.cas(invalid_uid) + + for invalid_run in ("", ".", "..", "../escape", "with/slash", "a" * 129): + with self.subTest(run=invalid_run): + with self.assertRaises(PathSafetyError): + paths.run_work(invalid_run) + with self.assertRaises(PathSafetyError): + paths.lease(invalid_run) + + def test_cas_accepts_only_the_content_uid(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + self.assertEqual(paths.cas(UID).entry, repository / "out" / "cas" / UID) + with self.assertRaises(TypeError): + paths.cas(UID, "legacy-readable-name") # type: ignore[call-arg] + + def test_confined_path_rejects_parent_escape_and_unexpected_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths = BuildPaths(repository) + + with self.assertRaisesRegex(PathSafetyError, "outside allowed root"): + paths.require_confined(paths.cas_root / ".." / "work", paths.cas_root) + with self.assertRaisesRegex(PathSafetyError, "unexpected allowed root"): + paths.require_confined(repository / "elsewhere", repository / "elsewhere") + with self.assertRaisesRegex(PathSafetyError, "outside repository"): + paths._reject_existing_reparse_points(repository.parent / "outside") + + def test_existing_reparse_component_and_leaf_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + regular = BuildPaths(repository) + regular.prepare() + entry = regular.cas(UID) + entry.entry.mkdir() + entry.touch.touch() + + reparse_paths = { + regular.cas_root, + entry.touch, + } + + def is_reparse(path: Path) -> bool: + return path in reparse_paths + + with patch("core.paths._is_reparse", side_effect=is_reparse): + paths = BuildPaths(repository) + with self.assertRaisesRegex(PathSafetyError, "reparse point"): + paths.cas(UID) + + reparse_paths.remove(regular.cas_root) + with self.assertRaisesRegex(PathSafetyError, "reparse point"): + paths.require_confined(entry.touch, paths.cas_root) + + def test_repository_must_be_an_existing_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + missing = Path(temporary) / "missing" + with self.assertRaisesRegex(PathSafetyError, "existing directory"): + BuildPaths(missing) + + file_path = Path(temporary) / "file" + file_path.touch() + with self.assertRaisesRegex(PathSafetyError, "existing directory"): + BuildPaths(file_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_python_coverage_graph.py b/build/tests/test_python_coverage_graph.py new file mode 100644 index 0000000..15f19b1 --- /dev/null +++ b/build/tests/test_python_coverage_graph.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.python_coverage import main as coverage_main # noqa: E402 +from graphs.python_coverage import python_coverage_graph # noqa: E402 + + +class PythonCoverageGraphTests(unittest.TestCase): + def repository(self, root: Path, executable: bool = True) -> Path: + build = root / "build" + for relative, content in ( + ("core/example.py", "VALUE = 1\n"), + ("graphs/example.py", "VALUE = 2\n"), + ("tests/test_example.py", "pass\n"), + ("driver.py", "VALUE = 4\n"), + ("main.py", "VALUE = 3\n"), + ("pyproject.toml", "[project]\nname='fixture'\n"), + ("uv.lock", "fixture\n"), + ): + path = build / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + coverage = build / ".venv/Scripts/coverage.exe" + package = build / ".venv/Lib/site-packages/coverage" + package.mkdir(parents=True) + (package / "version.py").write_text("__version__ = 'fixture'\n", encoding="utf-8") + cache = package / "__pycache__" + cache.mkdir() + (cache / "version.pyc").write_bytes(b"derived") + if executable: + coverage.parent.mkdir(parents=True) + coverage.write_bytes(b"coverage-launcher") + else: + coverage.mkdir(parents=True) + return root + + def test_single_gate_signs_all_first_party_tests_config_lock_and_exact_local_coverage(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = self.repository(Path(temporary) / "repo") + before = python_coverage_graph(repository) + node = before.node("python-coverage") + + self.assertEqual(before.targets, (node.name,)) + self.assertEqual(dict(before.pools), {"python-coverage": 1}) + self.assertEqual(dict(node.command.env), {"PYTHONDONTWRITEBYTECODE": "1"}) + self.assertEqual( + node.command.argv, + (sys.executable, "-m", "core.python_coverage", + str(repository / "build/.venv/Scripts/coverage.exe"), str(repository / "build")), + ) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in node.results), + ( + ("reports/coverage/python/coverage.json", "coverage", "application/json", "coverage.json"), + ("reports/coverage/python/coverage.xml", "coverage", "application/xml", "coverage.xml"), + ("reports/coverage/python/coverage.txt", "coverage", "text/plain", "coverage.txt"), + ("reports/coverage/python/coverage.toml", "coverage-config", "application/toml", "coverage.toml"), + ("reports/coverage/python/coverage.data", "coverage-data", "application/octet-stream", ".coverage"), + ), + ) + (repository / "build/tests/test_example.py").write_text("changed\n", encoding="utf-8") + changed_test = python_coverage_graph(repository) + (repository / "build/tests/test_example.py").write_text("pass\n", encoding="utf-8") + (repository / "build/driver.py").write_text("changed\n", encoding="utf-8") + changed_driver = python_coverage_graph(repository) + (repository / "build/driver.py").write_text("VALUE = 4\n", encoding="utf-8") + config = repository / "build/pyproject.toml" + original_config = config.read_text(encoding="utf-8") + config.write_text(original_config + "\n# changed\n", encoding="utf-8") + changed_config = python_coverage_graph(repository) + config.write_text(original_config, encoding="utf-8") + (repository / "build/.venv/Scripts/coverage.exe").write_bytes(b"changed-coverage") + changed_tool = python_coverage_graph(repository) + (repository / "build/.venv/Scripts/coverage.exe").write_bytes(b"coverage-launcher") + (repository / "build/.venv/Lib/site-packages/coverage/version.py").write_text( + "__version__ = 'changed'\n", encoding="utf-8" + ) + changed_package = python_coverage_graph(repository) + + self.assertNotEqual(node.uid, changed_test.node(node.name).uid) + self.assertNotEqual(node.uid, changed_driver.node(node.name).uid) + self.assertNotEqual(node.uid, changed_config.node(node.name).uid) + self.assertNotEqual(node.uid, changed_tool.node(node.name).uid) + self.assertNotEqual(node.uid, changed_package.node(node.name).uid) + + def test_missing_or_non_file_project_coverage_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + missing = self.repository(root / "missing") + (missing / "build/.venv/Scripts/coverage.exe").unlink() + with self.assertRaises(FileNotFoundError): + python_coverage_graph(missing) + directory = self.repository(root / "directory", executable=False) + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + python_coverage_graph(directory) + bad_package = self.repository(root / "bad-package") + package = bad_package / "build/.venv/Lib/site-packages/coverage" + (package / "version.py").unlink() + (package / "__pycache__/version.pyc").unlink() + (package / "__pycache__").rmdir() + package.rmdir() + package.write_text("not a directory", encoding="utf-8") + with self.assertRaisesRegex(FileNotFoundError, "not a directory"): + python_coverage_graph(bad_package) + + +class PythonCoverageWorkerTests(unittest.TestCase): + def project(self, root: Path) -> Path: + for relative, content in ( + ("core/__init__.py", ""), + ("core/value.py", "VALUE = 1\n"), + ("graphs/__init__.py", ""), + ("graphs/value.py", "VALUE = 2\n"), + ("driver.py", "VALUE = 4\n"), + ("main.py", "VALUE = 3\n"), + ("pyproject.toml", (BUILD_ROOT / "pyproject.toml").read_text(encoding="utf-8")), + ("tests/test_all.py", "import unittest\nfrom core.value import VALUE as CORE\nfrom graphs.value import VALUE as GRAPH\nimport driver\nimport main\nclass T(unittest.TestCase):\n def test_all(self): self.assertEqual((CORE, GRAPH, driver.VALUE, main.VALUE), (1, 2, 4, 3))\n"), + ): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return root + + def test_exact_cli_publishes_line_branch_reports_from_isolated_work_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project, work, output = self.project(root / "project"), root / "work", root / "output" + work.mkdir() + output.mkdir() + environment = {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)} + coverage = BUILD_ROOT / ".venv/Scripts/coverage.exe" + with mock.patch.dict(os.environ, environment, clear=False): + self.assertEqual(0, coverage_main((str(coverage), str(project)))) + + document = json.loads((output / "coverage.json").read_text(encoding="utf-8")) + self.assertEqual(100.0, document["totals"]["percent_covered"]) + self.assertTrue((output / "coverage.xml").is_file()) + self.assertIn("100%", (output / "coverage.txt").read_text(encoding="utf-8")) + self.assertEqual( + (output / "coverage.toml").read_bytes(), + (project / "pyproject.toml").read_bytes(), + ) + self.assertTrue((work / ".coverage").is_file()) + self.assertEqual((output / ".coverage").read_bytes(), (work / ".coverage").read_bytes()) + self.assertFalse((project / ".coverage").exists()) + + entry_work, entry_output = root / "entry-work", root / "entry-output" + entry_work.mkdir() + entry_output.mkdir() + with ( + mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(entry_work), + "OBSERVER_OUT_DIR": str(entry_output)}, clear=False), + mock.patch.object(sys, "argv", ["python_coverage.py", str(coverage), str(project)]), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_path(str(BUILD_ROOT / "core/python_coverage.py"), run_name="__main__") + self.assertEqual(0, raised.exception.code) + + def test_below_100_fails_after_publishing_evidence_and_environment_is_required(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project, work, output = self.project(root / "project"), root / "work", root / "output" + work.mkdir() + output.mkdir() + (project / "tests/test_all.py").write_text( + "import unittest\nfrom core.value import VALUE as CORE\nfrom graphs.value import VALUE as GRAPH\n" + "class T(unittest.TestCase):\n def test_packages(self): self.assertEqual((CORE, GRAPH), (1, 2))\n", + encoding="utf-8", + ) + coverage = BUILD_ROOT / ".venv/Scripts/coverage.exe" + with mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)}): + with self.assertRaises(subprocess.CalledProcessError): + coverage_main((str(coverage), str(project))) + self.assertLess(json.loads((output / "coverage.json").read_text())["totals"]["percent_covered"], 100) + self.assertTrue((output / "coverage.txt").is_file()) + + (project / "core/value.py").write_text( + "def choose(first, second):\n value = 0\n if first:\n value = 1\n if second:\n value = 2\n return value\n", + encoding="utf-8", + ) + (project / "tests/test_all.py").write_text( + "import unittest\nfrom core.value import choose\nfrom graphs.value import VALUE\nimport driver\nimport main\n" + "class T(unittest.TestCase):\n def test_one_branch(self): self.assertEqual((choose(True, True), VALUE, driver.VALUE, main.VALUE), (2, 2, 4, 3))\n", + encoding="utf-8", + ) + branch_work, branch_output = root / "branch-work", root / "branch-output" + branch_work.mkdir() + branch_output.mkdir() + with mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(branch_work), + "OBSERVER_OUT_DIR": str(branch_output)}): + with self.assertRaises(subprocess.CalledProcessError): + coverage_main((str(coverage), str(project))) + totals = json.loads((branch_output / "coverage.json").read_text())["totals"] + self.assertEqual(0, totals["missing_lines"]) + self.assertGreater(totals["missing_branches"], 0) + + with mock.patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(RuntimeError, "OBSERVER_BUILD_DIR"): + coverage_main((str(BUILD_ROOT / ".venv/Scripts/coverage.exe"), str(BUILD_ROOT))) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + work, output, tool, build_file = root / "work", root / "output", root / "coverage", root / "build" + work.mkdir() + output.mkdir() + tool.mkdir() + build_file.touch() + environment = {"OBSERVER_BUILD_DIR": str(work), "OBSERVER_OUT_DIR": str(output)} + for arguments in ((tool, BUILD_ROOT), (BUILD_ROOT / ".venv/Scripts/coverage.exe", build_file)): + with self.subTest(arguments=arguments), mock.patch.dict(os.environ, environment, clear=False): + with self.assertRaises(FileNotFoundError): + coverage_main(tuple(map(str, arguments))) + missing_config = root / "missing-config" + missing_config.mkdir() + with mock.patch.dict(os.environ, environment, clear=False), self.assertRaises(FileNotFoundError): + coverage_main((str(BUILD_ROOT / ".venv/Scripts/coverage.exe"), str(missing_config))) + with mock.patch.dict(os.environ, {"OBSERVER_BUILD_DIR": str(root / "missing"), + "OBSERVER_OUT_DIR": str(output)}, clear=True): + with self.assertRaisesRegex(RuntimeError, "OBSERVER_BUILD_DIR"): + coverage_main((str(BUILD_ROOT / ".venv/Scripts/coverage.exe"), str(BUILD_ROOT))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_quality_tools.py b/build/tests/test_quality_tools.py new file mode 100644 index 0000000..2e8cf6d --- /dev/null +++ b/build/tests/test_quality_tools.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError, dataclass +import hashlib +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +import sys + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.quality_tools import ( # noqa: E402 + QualityTools, + ResolvedDirectory, + ResolvedTool, + SanitizerRuntimes, + discover_quality_tools, + resolve_binskim, + resolve_dumpbin, + resolve_llvm, + resolve_asan_runtimes, + resolve_sanitizer_runtimes, + resolve_tool, + resolve_ubsan_runtime, + resolve_umdh, +) + + +@dataclass(frozen=True) +class FakeToolchain: + installation: Path + llvm_dir: Path + identity: tuple[tuple[str, str], ...] + + +class QualityToolsTests(unittest.TestCase): + def fixture(self, root: Path, *, kit11: bool = True) -> tuple[FakeToolchain, Path, Path]: + installation, llvm = root / "Visual Studio", root / "LLVM" + version = "14.44.35207" + paths = ( + llvm / "bin/clang-cl.exe", + llvm / "bin/clang-scan-deps.exe", + llvm / "bin/llvm-cov.exe", + llvm / "bin/llvm-profdata.exe", + installation / f"VC/Tools/MSVC/{version}/bin/Hostx64/x64/dumpbin.exe", + ) + for index, path in enumerate(paths): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(f"tool-{index}".encode()) + program_files = root / "Program Files (x86)" + kit = "11" if kit11 else "10" + umdh = program_files / f"Windows Kits/{kit}/Debuggers/x64/umdh.exe" + umdh.parent.mkdir(parents=True) + umdh.write_bytes(b"umdh") + binskim = root / "shims/BinSkim.exe" + binskim.parent.mkdir() + binskim.write_bytes(b"binskim") + identity = (("clang_tidy_version", "19.1.5"), ("vc_tools_version", version)) + return FakeToolchain(installation, llvm, identity), binskim, program_files + + def runtime_fixture( + self, toolchain: FakeToolchain, llvm_version: str = "19" + ) -> tuple[Path, tuple[Path, Path]]: + version = dict(toolchain.identity)["vc_tools_version"] + asan = toolchain.installation / f"VC/Tools/MSVC/{version}/bin/Hostx64" + for architecture, name in ( + ("x86", "clang_rt.asan_dynamic-i386.dll"), + ("x64", "clang_rt.asan_dynamic-x86_64.dll"), + ): + path = asan / architecture / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(f"asan-{architecture}".encode()) + directory = toolchain.llvm_dir / f"lib/clang/{llvm_version}/lib/windows" + libraries = tuple( + directory / name + for name in ( + "clang_rt.ubsan_standalone-x86_64.lib", + "clang_rt.ubsan_standalone_cxx-x86_64.lib", + ) + ) + directory.mkdir(parents=True) + for index, library in enumerate(libraries): + library.write_bytes(f"ubsan-{index}".encode()) + return directory, libraries # type: ignore[return-value] + + def test_resolves_exact_consumers_with_canonical_streaming_sha256_identities(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, binskim, program_files = self.fixture(Path(temporary)) + with ( + mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), + mock.patch("core.quality_tools.shutil.which", return_value=str(binskim)) as which, + mock.patch.object(Path, "read_bytes", side_effect=AssertionError("must stream")), + ): + tools = discover_quality_tools(toolchain) # type: ignore[arg-type] + expected_digests = {} + for tool in ( + tools.clang_cl, tools.clang_scan_deps, tools.llvm_cov, tools.llvm_profdata, + tools.dumpbin, tools.binskim, tools.umdh, + ): + with tool.path.open("rb") as stream: + expected_digests[tool.path] = hashlib.file_digest(stream, "sha256").hexdigest() + + which.assert_called_once_with("binskim") + self.assertIsInstance(tools, QualityTools) + expected_names = ( + "clang-cl.exe", "clang-scan-deps.exe", "llvm-cov.exe", "llvm-profdata.exe", + "dumpbin.exe", "BinSkim.exe", "umdh.exe", + ) + self.assertEqual( + tuple(tool.path.name for tool in ( + tools.clang_cl, tools.clang_scan_deps, tools.llvm_cov, tools.llvm_profdata, + tools.dumpbin, tools.binskim, tools.umdh, + )), + expected_names, + ) + for tool in ( + tools.clang_cl, tools.clang_scan_deps, tools.llvm_cov, tools.llvm_profdata, + tools.dumpbin, tools.binskim, tools.umdh, + ): + self.assertEqual(tool.identity[0], ("path", str(tool.path))) + self.assertEqual(tool.identity[1][0], "sha256") + self.assertEqual(tool.identity[1][1], expected_digests[tool.path]) + self.assertIn("Windows Kits\\11", str(tools.umdh.path)) + with self.assertRaises(FrozenInstanceError): + tools.binskim = ResolvedTool(tools.binskim.path, tools.binskim.identity) # type: ignore[misc] + + def test_windows_kit_10_is_the_deterministic_fallback(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, binskim, program_files = self.fixture(Path(temporary), kit11=False) + with mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), mock.patch( + "core.quality_tools.shutil.which", return_value=str(binskim) + ): + tools = discover_quality_tools(toolchain) # type: ignore[arg-type] + self.assertIn("Windows Kits\\10", str(tools.umdh.path)) + + def test_explicit_umdh_override_is_strict(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + umdh = Path(temporary) / "sdk-19041/Debuggers/x64/umdh.exe" + umdh.parent.mkdir(parents=True) + umdh.write_bytes(b"umdh-19041") + with mock.patch.dict(os.environ, {"OBSERVER_UMDH": str(umdh)}, clear=False): + self.assertEqual(umdh.resolve(), resolve_umdh().path) + umdh.unlink() + with self.assertRaisesRegex(FileNotFoundError, "missing UMDH override"): + resolve_umdh() + + def test_selective_resolvers_are_lazy_and_match_the_aggregate(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, binskim, program_files = self.fixture(Path(temporary)) + (toolchain.llvm_dir / "bin/llvm-cov.exe").unlink() + with ( + mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), + mock.patch("core.quality_tools.shutil.which", return_value=str(binskim)), + ): + self.assertEqual(resolve_llvm(toolchain, "clang-cl").path.name, "clang-cl.exe") + self.assertEqual( + resolve_llvm(toolchain, "clang-scan-deps").path.name, + "clang-scan-deps.exe", + ) + self.assertEqual(resolve_dumpbin(toolchain).path.name, "dumpbin.exe") + self.assertEqual(resolve_binskim().path, binskim.resolve()) + self.assertEqual(resolve_umdh().path.name, "umdh.exe") + with self.assertRaisesRegex(FileNotFoundError, "missing llvm-cov"): + discover_quality_tools(toolchain) # type: ignore[arg-type] + with self.assertRaisesRegex(ValueError, "unsupported LLVM tool: clang-tidy"): + resolve_llvm(toolchain, "clang-tidy") + + def test_sanitizer_runtimes_are_exact_typed_and_content_addressed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, _binskim, _program_files = self.fixture(Path(temporary)) + directory, libraries = self.runtime_fixture(toolchain) + with mock.patch.object(Path, "read_bytes", side_effect=AssertionError("must stream")): + runtimes = resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + + self.assertIsInstance(runtimes, SanitizerRuntimes) + self.assertEqual(runtimes.asan_x86.path.name, "clang_rt.asan_dynamic-i386.dll") + self.assertEqual(runtimes.asan_x64.path.name, "clang_rt.asan_dynamic-x86_64.dll") + self.assertIsInstance(runtimes.ubsan, ResolvedDirectory) + self.assertEqual(runtimes.ubsan.path, directory.resolve()) + self.assertEqual(tuple(tool.path for tool in runtimes.ubsan.files), libraries) + identity = dict(runtimes.ubsan.identity) + self.assertEqual(identity["path"], str(directory.resolve())) + for tool in runtimes.ubsan.files: + name = tool.path.name + self.assertEqual(identity[f"{name}.path"], str(tool.path)) + self.assertEqual(identity[f"{name}.sha256"], dict(tool.identity)["sha256"]) + with self.assertRaises(FrozenInstanceError): + runtimes.asan_x86 = runtimes.asan_x64 # type: ignore[misc] + + def test_sanitizer_resolvers_load_only_the_requested_runtime_family(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, _binskim, _program_files = self.fixture(Path(temporary)) + directory, libraries = self.runtime_fixture(toolchain) + version = dict(toolchain.identity)["vc_tools_version"] + asan_root = toolchain.installation / f"VC/Tools/MSVC/{version}/bin/Hostx64" + + libraries[0].unlink() + selected = resolve_asan_runtimes(toolchain, ("x64",)) # type: ignore[arg-type] + self.assertEqual(tuple(selected), (("x64", selected[0][1]),)) + self.assertEqual(selected[0][1].path.name, "clang_rt.asan_dynamic-x86_64.dll") + with self.assertRaisesRegex(ValueError, "unsupported ASan architecture: arm64"): + resolve_asan_runtimes(toolchain, ("arm64",)) # type: ignore[arg-type] + + libraries[0].write_bytes(b"ubsan-0") + for runtime in asan_root.rglob("*.dll"): + runtime.unlink() + ubsan = resolve_ubsan_runtime(toolchain) # type: ignore[arg-type] + self.assertEqual(ubsan.path, directory.resolve()) + + def test_sanitizer_runtime_errors_name_the_exact_missing_input(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + toolchain, _binskim, _program_files = self.fixture(root) + directory, libraries = self.runtime_fixture(toolchain) + version = dict(toolchain.identity)["vc_tools_version"] + missing_asan = ( + toolchain.installation + / f"VC/Tools/MSVC/{version}/bin/Hostx64/x86/clang_rt.asan_dynamic-i386.dll" + ) + missing_asan.unlink() + with self.assertRaisesRegex(FileNotFoundError, "missing MSVC ASan x86 runtime"): + resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + missing_asan.write_bytes(b"asan-x86") + libraries[1].unlink() + with self.assertRaisesRegex( + FileNotFoundError, "missing UBSan runtime clang_rt.ubsan_standalone_cxx-x86_64.lib" + ): + resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + libraries[0].unlink() + directory.rmdir() + with self.assertRaisesRegex(FileNotFoundError, "missing UBSan runtime directory"): + resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + + no_version = FakeToolchain(toolchain.installation, toolchain.llvm_dir, ()) + with self.assertRaisesRegex(FileNotFoundError, "missing MSVC vc_tools_version identity"): + resolve_dumpbin(no_version) # type: ignore[arg-type] + + def test_full_llvm_version_runtime_precedes_the_major_fallback(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + toolchain, _binskim, _program_files = self.fixture(Path(temporary)) + self.runtime_fixture(toolchain) + directory, _libraries = self.runtime_fixture(toolchain, "19.1.5") + runtimes = resolve_sanitizer_runtimes(toolchain) # type: ignore[arg-type] + self.assertEqual(runtimes.ubsan.path, directory.resolve()) + + def test_each_missing_tool_is_named_precisely(self) -> None: + cases = ( + ("clang-cl", "LLVM/bin/clang-cl.exe"), + ("clang-scan-deps", "LLVM/bin/clang-scan-deps.exe"), + ("llvm-cov", "LLVM/bin/llvm-cov.exe"), + ("llvm-profdata", "LLVM/bin/llvm-profdata.exe"), + ("dumpbin", "Visual Studio/VC/Tools/MSVC/14.44.35207/bin/Hostx64/x64/dumpbin.exe"), + ) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for index, (name, relative) in enumerate(cases): + toolchain, binskim, program_files = self.fixture(root / str(index)) + (root / str(index) / relative).unlink() + with self.subTest(name=name), mock.patch.dict( + os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False + ), mock.patch("core.quality_tools.shutil.which", return_value=str(binskim)), self.assertRaisesRegex( + FileNotFoundError, f"missing {name}" + ): + discover_quality_tools(toolchain) # type: ignore[arg-type] + + toolchain, _binskim, program_files = self.fixture(root / "binskim") + with mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), mock.patch( + "core.quality_tools.shutil.which", return_value=None + ), self.assertRaisesRegex(FileNotFoundError, "missing BinSkim"): + discover_quality_tools(toolchain) # type: ignore[arg-type] + + toolchain, binskim, program_files = self.fixture(root / "umdh") + (program_files / "Windows Kits/11/Debuggers/x64/umdh.exe").unlink() + with mock.patch.dict(os.environ, {"ProgramFiles(x86)": str(program_files)}, clear=False), mock.patch( + "core.quality_tools.shutil.which", return_value=str(binskim) + ), self.assertRaisesRegex(FileNotFoundError, "missing UMDH"): + discover_quality_tools(toolchain) # type: ignore[arg-type] + + def test_resolve_tool_rejects_non_files_and_content_changes_identity(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "tool.exe" + path.write_bytes(b"one") + before = resolve_tool(path, "example") + path.write_bytes(b"two") + after = resolve_tool(path, "example") + with self.assertRaisesRegex(FileNotFoundError, "missing absent"): + resolve_tool(None, "absent") + with self.assertRaisesRegex(FileNotFoundError, "missing directory"): + resolve_tool(root, "directory") + self.assertNotEqual(before.identity, after.identity) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_recipe.py b/build/tests/test_recipe.py new file mode 100644 index 0000000..c0ed051 --- /dev/null +++ b/build/tests/test_recipe.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys +import unittest + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import GraphError, Result # noqa: E402 +from core.recipe import Recipe, RecipeError # noqa: E402 + + +def rendered_recipe(**overrides: object) -> str: + document: dict[str, object] = { + "name": "analyze-renpy-x64", + "pool": "slot", + "inputs": ["compile-renpy-x64"], + "results": [ + { + "id": "analysis/renpy/x64/sarif", + "kind": "report", + "media_type": "application/sarif+json", + "path": "analysis/renpy-x64.sarif", + } + ], + "script": { + "exec": ["pwsh.exe", "-NoProfile", "-Command", "-"], + "data": "Write-Output 'привет'\r\n", + }, + } + document.update(overrides) + return json.dumps(document, ensure_ascii=False) + + +class RecipeTests(unittest.TestCase): + def test_repository_recipe_is_parsed_without_reordering_process_data(self) -> None: + recipe = Recipe.parse(rendered_recipe()) + + self.assertEqual(recipe.name, "analyze-renpy-x64") + self.assertEqual(recipe.pool, "slot") + self.assertEqual(recipe.inputs, ("compile-renpy-x64",)) + self.assertEqual(recipe.argv, ("pwsh.exe", "-NoProfile", "-Command", "-")) + self.assertEqual(recipe.data, "Write-Output 'привет'\r\n".encode()) + self.assertEqual( + recipe.results, + ( + Result( + "analysis/renpy/x64/sarif", + "report", + "application/sarif+json", + "analysis/renpy-x64.sarif", + ), + ), + ) + + def test_node_bridge_uses_signed_uid_direct_dependencies_and_process_data(self) -> None: + recipe = Recipe.parse(rendered_recipe()) + current = recipe.to_node( + uid="0123456789abcdef0123456789abcdef", + env={"OBSERVER_OUT_DIR": r"C:\repo\out", "ZED": "last"}, + cwd=r"C:\repo\out\work\one", + ) + + self.assertEqual(current.name, recipe.name) + self.assertEqual(current.inputs, ("compile-renpy-x64",)) + self.assertEqual(current.command.argv, recipe.argv) + self.assertEqual(current.command.stdin, recipe.data) + self.assertEqual(current.command.cwd, r"C:\repo\out\work\one") + self.assertEqual(current.results, recipe.results) + self.assertEqual( + current.command.env, + (("OBSERVER_OUT_DIR", r"C:\repo\out"), ("ZED", "last")), + ) + + def test_only_required_repository_fields_are_interpreted(self) -> None: + recipe = Recipe.parse(rendered_recipe(metadata={"owner": "repository"})) + duplicate_name = rendered_recipe().replace( + '{"name": "analyze-renpy-x64",', + '{"name": "old", "name": "analyze-renpy-x64",', + ) + + self.assertEqual(recipe.name, "analyze-renpy-x64") + self.assertEqual(Recipe.parse(duplicate_name).name, "analyze-renpy-x64") + + def test_invalid_json_or_missing_required_fields_are_rejected(self) -> None: + with self.assertRaises(RecipeError): + Recipe.parse("not json") + + valid = json.loads(rendered_recipe()) + for field in ("name", "pool", "inputs", "script"): + document = dict(valid) + del document[field] + with self.subTest(field=field), self.assertRaises(RecipeError): + Recipe.parse(json.dumps(document)) + + for field in ("exec", "data"): + script = dict(valid["script"]) + del script[field] + with self.subTest(script_field=field), self.assertRaises(RecipeError): + Recipe.parse(json.dumps({**valid, "script": script})) + + malformed_results = ( + {"id": "report", "kind": "report", "media_type": "application/json"}, + {"id": "report", "kind": "report", "media_type": "invalid", "path": "x"}, + "not-a-list", + ) + for results in malformed_results: + with self.subTest(results=results), self.assertRaises(RecipeError): + Recipe.parse(rendered_recipe(results=results)) + + def test_recipe_requires_the_typed_results_field(self) -> None: + document = json.loads(rendered_recipe()) + del document["results"] + + with self.assertRaises(RecipeError): + Recipe.parse(json.dumps(document)) + + def test_graph_and_process_descriptors_remain_validation_boundaries(self) -> None: + recipe = Recipe.parse(rendered_recipe()) + + with self.assertRaises(GraphError): + recipe.to_node(uid="not-md5") + with self.assertRaises(GraphError): + Recipe.parse(rendered_recipe(name="unsafe name")).to_node(uid="0" * 32) + with self.assertRaises(GraphError): + Recipe.parse( + rendered_recipe(script={"exec": ["bad\0argv"], "data": ""}) + ).to_node(uid="0" * 32) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_render.py b/build/tests/test_render.py new file mode 100644 index 0000000..612b76f --- /dev/null +++ b/build/tests/test_render.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import hashlib +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from jinja2 import UndefinedError + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.render import TemplateRenderer, ps_quote # noqa: E402 + + +class TemplateRendererTests(unittest.TestCase): + def setUp(self) -> None: + self.templates = BUILD_ROOT / "templates" + self.renderer = TemplateRenderer(self.templates) + + def variables(self) -> dict[str, object]: + return { + "name": "build-renpy-x64", + "pool": "slot", + "inputs": ["src/modules/renpy/renpy.vcxproj"], + "results": [], + "pwsh": "pwsh.exe", + "msbuild": r"C:\Program Files\O'Brien Tools\MSBuild.exe", + "project": r"C:\repo\RPG's\renpy.vcxproj", + "target": "Build", + "configuration": "Release", + "platform": "x64", + "msbuild_args": ["/p:WarningsAsErrors=true"], + } + + def test_msbuild_leaf_inherits_complete_json_recipe(self) -> None: + rendered = self.renderer.render("msbuild.ps1", self.variables()) + recipe = json.loads(rendered) + + self.assertEqual(recipe["name"], "build-renpy-x64") + self.assertEqual(recipe["pool"], "slot") + self.assertNotIn("outputs", recipe) + self.assertEqual(recipe["results"], []) + self.assertEqual( + recipe["script"]["exec"], + [ + "pwsh.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$ErrorActionPreference = 'Stop'; " + "& ([ScriptBlock]::Create([Console]::In.ReadToEnd()))", + ], + ) + self.assertNotIn("shell", recipe["script"]) + self.assertIn( + "'C:\\Program Files\\O''Brien Tools\\MSBuild.exe'", + recipe["script"]["data"], + ) + self.assertIn("'C:\\repo\\RPG''s\\renpy.vcxproj'", recipe["script"]["data"]) + self.assertIn("'/p:Configuration=Release'", recipe["script"]["data"]) + self.assertIn("'/p:WarningsAsErrors=true'", recipe["script"]["data"]) + self.assertIn("$env:OBSERVER_OUT_DIR", recipe["script"]["data"]) + self.assertIn("$env:OBSERVER_BUILD_DIR", recipe["script"]["data"]) + self.assertNotIn(r"C:\repo\out\cas", recipe["script"]["data"]) + self.assertIn("${LASTEXITCODE}: $FilePath", recipe["script"]["data"]) + + def test_powershell_stdin_transport_reports_parser_errors(self) -> None: + variables = self.variables() + variables["pwsh"] = shutil.which("pwsh") + argv = json.loads(self.renderer.render("msbuild.ps1", variables))["script"][ + "exec" + ] + result = subprocess.run( + argv, + input=b"$invalid:\n", + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + + def test_missing_variable_fails_before_a_recipe_is_created(self) -> None: + variables = self.variables() + del variables["platform"] + + with self.assertRaisesRegex(UndefinedError, "platform.*undefined"): + self.renderer.render("msbuild.ps1", variables) + + def test_inherited_recipe_renders_declared_typed_results(self) -> None: + variables = self.variables() + variables["results"] = [ + { + "id": "binary/renpy/x64", + "kind": "module", + "media_type": "application/vnd.microsoft.portable-executable", + "path": "bin/renpy.dll", + } + ] + + recipe = json.loads(self.renderer.render("msbuild.ps1", variables)) + + self.assertEqual(recipe["results"], variables["results"]) + + def test_power_shell_quote_is_a_single_literal(self) -> None: + self.assertEqual(ps_quote("plain"), "'plain'") + self.assertEqual(ps_quote("O'Brien"), "'O''Brien'") + self.assertEqual(ps_quote(Path(r"C:\A B\file.txt")), r"'C:\A B\file.txt'") + + def test_msbuild_family_template_stays_reviewable(self) -> None: + meaningful_lines = [ + line + for line in (self.templates / "msbuild.ps1").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + self.assertLessEqual(len(meaningful_lines), 18) + + def test_output_postconditions_share_one_strict_template_macro(self) -> None: + helper = self.templates / "_output.ps1" + leaves = ( + "catch2-test.ps1", + "clang-command.ps1", + "clang-tidy.ps1", + "fuzz-build.ps1", + "msvc-analyze.ps1", + "sanitizer-test.ps1", + "source-dependencies.ps1", + "vcpkg.ps1", + ) + + self.assertTrue(helper.is_file()) + self.assertIn("macro require_output", helper.read_text(encoding="utf-8")) + for name in leaves: + with self.subTest(template=name): + content = (self.templates / name).read_text(encoding="utf-8") + self.assertIn('from "_output.ps1" import require_output', content) + self.assertNotIn("if (-not (Test-Path", content) + + variables = self.variables() | { + "llvm_dir": "llvm", + "source": "unit.cpp", + "vcpkg_installed": "installed", + "vcpkg_root": "vcpkg", + } + with self.assertRaisesRegex(UndefinedError, "project_name.*undefined"): + self.renderer.render("msvc-analyze.ps1", variables) + + def test_catch2_shards_share_a_short_base_without_changing_rendered_bytes(self) -> None: + base = self.templates / "catch2-test.ps1" + self.assertTrue(base.is_file(), "Catch2 shard recipes must share one inherited base") + + variables: dict[str, object] = { + "name": "fixture", + "pool": "slot", + "inputs": ["build-tests"], + "results": [], + "pwsh": "pwsh.exe", + "artifacts": [ + {"name": "tests.exe", "source": "C:/cas/tests.exe"}, + {"name": "renpy.so", "source": "C:/cas/renpy.so"}, + ], + "shard_count": 4, + "shard_index": 2, + } + cases = { + "native-test.ps1": ( + variables, + "7c5777763ff4cd93f343b773511bec55c61acc717128c9bfff8d6735bcb96f83", + ), + "native-corpus-test.ps1": ( + variables, + "85e7f28d606dac5a70d01be851b5e17ed47a650a8287c32163881dbce21a6033", + ), + "coverage-test.ps1": ( + variables, + "2ea01c93809ed7985d11147e0a2a060b945c7b2f6645c5d0c7375c023ecaeacc", + ), + "sanitizer-test.ps1": ( + variables + | { + "runtime": { + "name": "clang_rt.asan_dynamic-x86_64.dll", + "source": "C:/llvm/asan.dll", + }, + "options_name": "ASAN_OPTIONS", + "options_value": "halt_on_error=1", + }, + "261dc649182fe2353fcb0393a3eeb2a70ff9f17879adb654048305d6ae4289f1", + ), + "sanitizer-test.ps1:ubsan": ( + variables + | { + "runtime": None, + "options_name": "UBSAN_OPTIONS", + "options_value": "halt_on_error=1", + }, + "47ddf49be4a5df8279eb7a6bf5229550da255d3256395493e30fad45d8726800", + ), + } + for case, (values, expected) in cases.items(): + template = case.partition(":")[0] + with self.subTest(case=case): + rendered = self.renderer.render(template, values).encode() + self.assertEqual(hashlib.sha256(rendered).hexdigest(), expected) + + for name in ("native-test.ps1", "coverage-test.ps1", "sanitizer-test.ps1"): + with self.subTest(template=name): + content = (self.templates / name).read_text(encoding="utf-8") + self.assertIn('{% extends "catch2-test.ps1" %}', content) + self.assertLessEqual(len(content.splitlines()), 10) + + def test_template_root_must_be_an_existing_directory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + file_path = Path(directory) / "template.txt" + file_path.write_text("content", encoding="utf-8") + + with self.assertRaises(NotADirectoryError): + TemplateRenderer(file_path) + with self.assertRaises(FileNotFoundError): + TemplateRenderer(Path(directory) / "missing") + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_result_export.py b/build/tests/test_result_export.py new file mode 100644 index 0000000..b111cf5 --- /dev/null +++ b/build/tests/test_result_export.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import stat +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +import core.result_export as result_export # noqa: E402 +from core.graph import Command, Graph, Node, Result # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from core.result_export import ResultExportError, export_results # noqa: E402 +from core.store import CasStore # noqa: E402 + + +def node(name: str, *results: Result) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "cpu", + Command(("tool",)), + results=results, + ) + + +class ResultExportTests(unittest.TestCase): + def fixture(self, root: Path) -> tuple[BuildPaths, CasStore]: + repository = root / "repo" + repository.mkdir() + paths = BuildPaths(repository) + paths.prepare() + return paths, CasStore(paths, "export-test") + + @staticmethod + def prepare(store: CasStore, current: Node, *, complete: bool = True) -> Path: + cas = store.prepare_entry(current) + cas.log.write_text(f"log for {current.name}\n", encoding="utf-8") + if complete: + store.mark_complete(current) + return cas.output + + def test_files_and_directories_publish_by_logical_id_with_manifest_last(self) -> None: + package = Result( + "packages/x64/renpy.zip", "package", "application/zip", "artifacts/renpy.zip" + ) + coverage = Result( + "reports/coverage/x64", "report", "application/json", "coverage" + ) + producer = node("publish", package, coverage) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + output = self.prepare(store, producer) + (output / "artifacts").mkdir() + (output / "artifacts/renpy.zip").write_bytes(b"archive") + (output / "coverage/sub").mkdir(parents=True) + (output / "coverage/index.json").write_bytes(b"{}") + (output / "coverage/sub/detail.json").write_bytes(b'{"ok":true}') + destination = root / "export" + + write_manifest = result_export._write_manifest + manifest_order: list[Path] = [] + + def observe(staging: Path, document: dict[str, object]) -> None: + manifest_order.append(staging) + root_staging = staging.parent if staging.name == "packages" else staging + self.assertTrue((root_staging / "packages/x64/renpy.zip").is_file()) + self.assertTrue((root_staging / coverage.id / "sub/detail.json").is_file()) + self.assertFalse((root_staging / "manifest.json").exists()) + if staging.name == "packages": + self.assertEqual( + [entry["path"] for entry in document["results"]], + ["x64/renpy.zip"], + ) + else: + self.assertTrue((staging / "packages/manifest.json").is_file()) + self.assertEqual( + [entry["path"] for entry in document["results"]], + [coverage.id], + ) + write_manifest(staging, document) + + with mock.patch.object(result_export, "_write_manifest", side_effect=observe) as write: + published = export_results(graph, store, destination, "verify", "success") + + self.assertEqual(published, destination) + self.assertEqual(write.call_count, 2) + self.assertEqual(manifest_order[0], manifest_order[1] / "packages") + self.assertEqual((destination / "packages/x64/renpy.zip").read_bytes(), b"archive") + manifest = json.loads((destination / "manifest.json").read_text(encoding="utf-8")) + package_manifest = json.loads( + (destination / "packages/manifest.json").read_text(encoding="utf-8") + ) + + self.assertEqual(manifest["schema"], 1) + self.assertEqual(manifest["command"], "verify") + self.assertEqual(manifest["status"], "success") + self.assertEqual(manifest["failures"], []) + self.assertEqual(manifest["logs"], []) + self.assertEqual([entry["path"] for entry in manifest["results"]], [coverage.id]) + self.assertEqual(package_manifest["schema"], 1) + self.assertEqual(package_manifest["command"], "verify") + self.assertEqual(package_manifest["status"], "success") + self.assertEqual(package_manifest["failures"], []) + self.assertEqual(package_manifest["logs"], []) + self.assertEqual( + [entry["path"] for entry in package_manifest["results"]], + ["x64/renpy.zip"], + ) + file_entry = package_manifest["results"][0] + self.assertEqual(file_entry["id"], package.id) + directory_entry = manifest["results"][0] + self.assertEqual(file_entry["size"], 7) + self.assertEqual(file_entry["sha256"], hashlib.sha256(b"archive").hexdigest()) + self.assertEqual(file_entry["object_type"], "file") + self.assertEqual(directory_entry["size"], len(b"{}") + len(b'{"ok":true}')) + self.assertEqual(directory_entry["object_type"], "directory") + self.assertRegex(directory_entry["sha256"], r"^[0-9a-f]{64}$") + self.assertNotIn(str(root), json.dumps(manifest)) + self.assertNotIn(str(root), json.dumps(package_manifest)) + + def test_failed_export_includes_existing_logs_and_only_complete_results(self) -> None: + report = Result("reports/ready.json", "report", "application/json", "ready.json") + package = Result( + "packages/x64/ready.zip", "package", "application/zip", "ready.zip" + ) + partial = Result("reports/partial.json", "report", "application/json", "partial.json") + ready, failed, pending = node("ready", report, package), node("failed", partial), node("pending") + graph = Graph( + (ready, failed, pending), + (ready.name, failed.name, pending.name), + {"cpu": 1}, + ) + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + ready_output = self.prepare(store, ready) + (ready_output / "ready.json").write_bytes(b"ready") + (ready_output / "ready.zip").write_bytes(b"package") + failed_output = self.prepare(store, failed, complete=False) + (failed_output / "partial.json").write_bytes(b"partial") + + destination = root / "failed-export" + export_results( + graph, + store, + destination, + "verify", + "failed", + failures=(failed.name,), + ) + manifest = json.loads((destination / "manifest.json").read_text(encoding="utf-8")) + + self.assertTrue((destination / report.id).is_file()) + self.assertFalse((destination / "packages").exists()) + self.assertFalse((destination / partial.id).exists()) + self.assertEqual((destination / "logs/ready.log").read_text(), "log for ready\n") + self.assertEqual((destination / "logs/failed.log").read_text(), "log for failed\n") + + self.assertEqual(manifest["failures"], [failed.name]) + self.assertEqual([entry["id"] for entry in manifest["results"]], [report.id]) + self.assertEqual( + [entry["path"] for entry in manifest["logs"]], + ["logs/ready.log", "logs/failed.log"], + ) + + def test_root_manifest_publishes_cache_identity_report(self) -> None: + producer = node("cached") + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + cache = { + "schema": 1, + "summary": {"executed": 0, "failed": 0, "hit": 1, "incomplete": 0}, + "nodes": [{ + "duration_ms": 0, + "name": producer.name, + "state": "hit", + "uid": producer.uid, + }], + } + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + self.prepare(store, producer) + destination = root / "export" + + export_results( + graph, store, destination, "verify", "success", cache=cache + ) + manifest = json.loads( + (destination / "manifest.json").read_text(encoding="utf-8") + ) + + self.assertEqual(manifest["cache"], cache) + + def test_existing_destination_missing_result_and_path_collisions_are_rejected(self) -> None: + cases = ( + "existing", + "missing", + "component", + "collision", + "reserved", + "package-reserved", + "packages-root", + ) + for case in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + results = { + "collision": ( + Result("reports", "report", "application/json", "one.json"), + Result("reports/child", "report", "application/json", "two.json"), + ), + "reserved": ( + Result("manifest.json", "report", "application/json", "one.json"), + ), + "package-reserved": ( + Result( + "packages/manifest.json", + "package", + "application/json", + "one.json", + ), + ), + "packages-root": ( + Result("packages", "report", "application/json", "one.json"), + ), + }.get(case, (Result("report", "report", "application/json", "one.json"),)) + producer = node("producer", *results) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + output = self.prepare(store, producer) + if case != "missing": + (output / "one.json").write_bytes(b"one") + if case == "component": + producer = node( + "nested", + Result("nested", "report", "application/json", "one.json/child.json"), + ) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + nested_output = self.prepare(store, producer) + (nested_output / "one.json").write_bytes(b"not a directory") + if case == "collision": + (output / "two.json").write_bytes(b"two") + destination = root / "export" + if case == "existing": + destination.mkdir() + (destination / "sentinel").write_bytes(b"keep") + + with self.assertRaises(ResultExportError): + export_results(graph, store, destination, "verify", "success") + + if case == "existing": + self.assertEqual((destination / "sentinel").read_bytes(), b"keep") + else: + self.assertFalse(destination.exists()) + + def test_reparse_sources_and_publication_failure_leave_no_partial_destination(self) -> None: + result = Result("reports/result.json", "report", "application/json", "result.json") + package = Result("packages/x64/result.zip", "package", "application/zip", "result.zip") + producer = node("producer", result, package) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + + for case in ("reparse", "manifest-failure"): + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + output = self.prepare(store, producer) + source = output / result.relative_path + source.write_bytes(b"result") + (output / package.relative_path).write_bytes(b"package") + destination = root / "export" + if case == "reparse": + patch = mock.patch.object( + result_export, "_is_reparse", side_effect=lambda path: path == source + ) + else: + write_manifest = result_export._write_manifest + + def fail_root_manifest( + staging: Path, document: dict[str, object] + ) -> None: + if staging.name == "packages": + write_manifest(staging, document) + return + raise OSError("disk full") + + patch = mock.patch.object( + result_export, "_write_manifest", side_effect=fail_root_manifest + ) + + with patch, self.assertRaises(ResultExportError): + export_results(graph, store, destination, "verify", "success") + + self.assertFalse(destination.exists()) + self.assertEqual([child.name for child in root.iterdir()], ["repo"]) + + def test_unsupported_entries_logs_and_destination_paths_are_rejected(self) -> None: + directory = Result("tree", "report", "application/octet-stream", "tree") + producer = node("producer", directory) + graph = Graph((producer,), (producer.name,), {"cpu": 1}) + cases = ("root-special", "child-special", "log-directory", "missing-parent", "dest-reparse") + for case in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + output = self.prepare(store, producer) + (output / "tree").mkdir() + special = output / "tree/special" + special.write_bytes(b"special") + destination = root / "export" + if case == "root-special": + patch = mock.patch.object( + result_export, + "_source", + return_value=(output / "tree", SimpleNamespace(st_mode=stat.S_IFIFO)), + ) + elif case == "child-special": + checked = result_export._checked + patch = mock.patch.object( + result_export, + "_checked", + side_effect=lambda path: ( + SimpleNamespace(st_mode=stat.S_IFIFO) + if path == special + else checked(path) + ), + ) + elif case == "log-directory": + cas = store.paths_for(producer) + cas.log.unlink() + cas.log.mkdir() + patch = mock.patch.object(store, "is_complete", return_value=False) + elif case == "missing-parent": + destination = root / "missing/export" + patch = mock.patch.object(result_export, "_write_manifest", wraps=result_export._write_manifest) + else: + patch = mock.patch.object( + result_export, "_is_reparse", side_effect=lambda path: path == root + ) + + status = "failed" if case == "log-directory" else "success" + with patch, self.assertRaises(ResultExportError): + export_results(graph, store, destination, "verify", status) + + self.assertFalse(destination.exists()) + + def test_publication_race_does_not_replace_the_winner(self) -> None: + only = node("only") + graph = Graph((only,), (only.name,), {"cpu": 1}) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + destination = root / "export" + lstat = result_export._lstat + destination_checks = 0 + + def raced(path: Path): + nonlocal destination_checks + if path == destination: + destination_checks += 1 + if destination_checks == 2: + return SimpleNamespace(st_mode=stat.S_IFDIR) + return lstat(path) + + with mock.patch.object(result_export, "_lstat", side_effect=raced): + with self.assertRaisesRegex(ResultExportError, "already exists"): + export_results(graph, store, destination, "verify", "success") + + self.assertFalse(destination.exists()) + + def test_manifest_inputs_are_closed_over_graph_names_and_status(self) -> None: + only = node("only") + graph = Graph((only,), (only.name,), {"cpu": 1}) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + _paths, store = self.fixture(root) + invalid = ( + (42, "success", ()), + (r"C:\absolute\command.exe", "success", ()), + ("verify", "unknown", ()), + ("verify", "success", ("missing",)), + ("verify", "success", (only.name,)), + ("verify", "failed", (only.name, only.name)), + ) + for index, (command, status, failures) in enumerate(invalid): + with self.subTest(command=command, status=status, failures=failures): + with self.assertRaises(ResultExportError): + export_results( + graph, + store, + root / f"export-{index}", + command, + status, + failures=failures, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_runtime.py b/build/tests/test_runtime.py new file mode 100644 index 0000000..38f5175 --- /dev/null +++ b/build/tests/test_runtime.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import asyncio +import hashlib +from pathlib import Path +import shutil +import sys +import tempfile +import unittest +from unittest import mock + +from filelock import FileLock, Timeout + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.clean import CleanError, clean # noqa: E402 +from core.graph import Command, Graph, Node # noqa: E402 +from core.runtime import BuildRuntime, ProcessFailed # noqa: E402 + + +RUN_ID = "20260801-runtime" + + +def node( + name: str = "analyze-renpy.pickle", + *, + env: tuple[tuple[str, str], ...] = (), + inputs: tuple[str, ...] = (), +) -> Node: + return Node( + name=name, + uid=hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + pool="cpu", + command=Command( + (r"C:\tools\analyze.exe", "literal argument"), + env=env, + cwd=r"C:\repo\source", + stdin=b"exact recipe\r\n", + ), + inputs=inputs, + ) + + +class FakeRunner: + def __init__(self, exit_code: int = 0) -> None: + self.exit_code = exit_code + self.calls: list[tuple[Command, Path]] = [] + + async def run(self, command: Command, *, log) -> int: + self.calls.append((command, Path(log.name))) + log.write(b"runner log") + log.flush() + return self.exit_code + + +class BuildRuntimeTests(unittest.IsolatedAsyncioTestCase): + async def test_lock_uses_persistent_native_async_filelock(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner()) + current = node() + + with mock.patch("core.runtime.AsyncFileLock") as factory: + lock = runtime.lock(current) + + self.assertIs(lock, factory.return_value) + factory.assert_called_once_with( + runtime.paths.lock(current.uid), + timeout=-1, + poll_interval=0.05, + fallback_to_soft=False, + preserve_lock_file=True, + ) + + async def test_success_removes_only_exact_scratch_and_empty_run_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + runtime.paths.prepare() + current = node(env=(("Alpha", "one"),)) + + work = runtime.paths.run_work(RUN_ID) / current.uid + with ( + mock.patch("core.runtime.shutil.rmtree", wraps=shutil.rmtree) as remove, + mock.patch("asyncio.to_thread", wraps=asyncio.to_thread) as offload, + ): + await runtime.run(current) + + command, log_path = runner.calls[0] + cas = runtime.store.paths_for(current) + self.assertEqual(command.argv, current.command.argv) + self.assertEqual(command.cwd, current.command.cwd) + self.assertEqual(command.stdin, current.command.stdin) + self.assertEqual( + dict(command.env), + { + "Alpha": "one", + "OBSERVER_BUILD_DIR": str(work), + "OBSERVER_OUT_DIR": str(cas.output), + "_MSPDBSRV_ENDPOINT_": f"observer_{current.uid}", + }, + ) + remove.assert_called_once_with(work) + offload.assert_awaited_once_with(remove, work) + self.assertFalse(work.exists()) + self.assertFalse(work.parent.exists()) + self.assertEqual(log_path, cas.log) + self.assertEqual(cas.log.read_bytes(), b"runner log") + self.assertFalse(cas.touch.exists()) + + async def test_long_node_name_does_not_enter_mutable_scratch_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + runtime.paths.prepare() + current = node("restore-vcpkg-asan-x86-" + "dependency" * 10) + + await runtime.run(current) + + command, _log_path = runner.calls[0] + work = Path(dict(command.env)["OBSERVER_BUILD_DIR"]) + self.assertEqual(work, runtime.paths.run_work(RUN_ID) / current.uid) + self.assertNotIn(current.name, str(work)) + + async def test_runtime_injects_unique_mspdbsrv_endpoint_per_uid(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + runtime.paths.prepare() + nodes = (node("build-renpy-x86-debug"), node("build-renpy-x64-debug")) + + for current in nodes: + await runtime.run(current) + + endpoints = tuple( + dict(command.env)["_MSPDBSRV_ENDPOINT_"] for command, _log in runner.calls + ) + self.assertEqual(endpoints, tuple(f"observer_{current.uid}" for current in nodes)) + self.assertEqual(len(set(endpoints)), len(nodes)) + + async def test_run_rejects_case_insensitive_runtime_environment_conflicts(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + runtime.paths.prepare() + + with self.assertRaisesRegex(ValueError, "OBSERVER_OUT_DIR"): + await runtime.run(node(env=(("observer_out_dir", "hostile"),))) + + self.assertEqual(runner.calls, []) + + with self.assertRaisesRegex(ValueError, "_MSPDBSRV_ENDPOINT_"): + await runtime.run(node(env=(("_mspdbsrv_endpoint_", "shared"),))) + + self.assertEqual(runner.calls, []) + + async def test_executor_runs_and_publishes_success(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runner = FakeRunner() + runtime = BuildRuntime(repository, RUN_ID, process_runner=runner) + current = node() + graph = Graph((current,), (current.name,), {"cpu": 1}) + + self.assertFalse(runtime.paths.output_root.exists()) + await runtime.executor(graph).run() + + self.assertEqual(len(runner.calls), 1) + self.assertTrue(runtime.is_complete(current)) + self.assertEqual(runtime.store.paths_for(current).touch.stat().st_size, 0) + + async def test_cache_report_distinguishes_executed_and_restored_nodes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + current = node() + graph = Graph((current,), (current.name,), {"cpu": 1}) + + cold_runner = FakeRunner() + cold = BuildRuntime(repository, "cold-run", process_runner=cold_runner) + await cold.executor(graph).run() + cold_report = cold.cache_report() + + warm_runner = FakeRunner() + warm = BuildRuntime(repository, "warm-run", process_runner=warm_runner) + await warm.executor(graph).run() + warm_report = warm.cache_report() + + self.assertEqual(cold_report["schema"], 1) + self.assertEqual( + cold_report["summary"], + {"executed": 1, "failed": 0, "hit": 0, "incomplete": 0}, + ) + self.assertEqual(cold_report["nodes"][0]["name"], current.name) + self.assertEqual(cold_report["nodes"][0]["uid"], current.uid) + self.assertEqual(cold_report["nodes"][0]["state"], "executed") + self.assertGreaterEqual(cold_report["nodes"][0]["duration_ms"], 0) + self.assertEqual(cold.live_uids(), (current.uid,)) + + self.assertEqual( + warm_report["summary"], + {"executed": 0, "failed": 0, "hit": 1, "incomplete": 0}, + ) + self.assertEqual(warm_report["nodes"], [ + { + "duration_ms": 0, + "name": current.name, + "state": "hit", + "uid": current.uid, + } + ]) + self.assertEqual(warm.live_uids(), (current.uid,)) + self.assertEqual(len(cold_runner.calls), 1) + self.assertEqual(warm_runner.calls, []) + + async def test_warm_cached_target_retains_and_reports_its_complete_dependency(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + dependency = node("dependency") + target = node("target", inputs=(dependency.name,)) + graph = Graph((dependency, target), (target.name,), {"cpu": 1}) + + cold = BuildRuntime(repository, "cold-run", process_runner=FakeRunner()) + await cold.executor(graph).run() + warm = BuildRuntime(repository, "warm-run", process_runner=FakeRunner()) + await warm.executor(graph).run() + + self.assertEqual( + warm.live_uids(graph), tuple(sorted((dependency.uid, target.uid))) + ) + self.assertEqual( + warm.cache_report(graph)["summary"], + {"executed": 0, "failed": 0, "hit": 2, "incomplete": 0}, + ) + + async def test_cache_report_excludes_failed_nodes_from_live_uids(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + current = node() + graph = Graph((current,), (current.name,), {"cpu": 1}) + runtime = BuildRuntime( + repository, "failed-run", process_runner=FakeRunner(23) + ) + + with self.assertRaises(ExceptionGroup): + await runtime.executor(graph).run() + + self.assertEqual(runtime.cache_report()["summary"], { + "executed": 0, + "failed": 1, + "hit": 0, + "incomplete": 0, + }) + self.assertEqual(runtime.cache_report()["nodes"][0]["state"], "failed") + self.assertEqual(runtime.live_uids(), ()) + + async def test_cache_report_marks_observed_unpublished_nodes_incomplete(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, "pending-run", process_runner=FakeRunner()) + current = node() + + self.assertFalse(runtime.is_complete(current)) + + self.assertEqual(runtime.cache_report()["summary"], { + "executed": 0, + "failed": 0, + "hit": 0, + "incomplete": 1, + }) + self.assertEqual(runtime.cache_report()["nodes"][0]["state"], "incomplete") + self.assertEqual(runtime.live_uids(), ()) + + async def test_executor_holds_per_run_lease_without_serializing_distinct_runs(self) -> None: + class ConcurrentRunner: + entered = 0 + both = asyncio.Event() + release = asyncio.Event() + + async def run(self, _command: Command, *, log) -> int: + type(self).entered += 1 + if type(self).entered == 2: + type(self).both.set() + await type(self).release.wait() + return 0 + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + first = BuildRuntime(repository, "run-one", process_runner=ConcurrentRunner()) + second = BuildRuntime(repository, "run-two", process_runner=ConcurrentRunner()) + graphs = tuple( + Graph((current,), (current.name,), {"cpu": 1}) + for current in (node("first"), node("second")) + ) + tasks = tuple( + asyncio.create_task(runtime.executor(graph).run()) + for runtime, graph in zip((first, second), graphs, strict=True) + ) + await asyncio.wait_for(ConcurrentRunner.both.wait(), timeout=2) + + for run_id in ("run-one", "run-two"): + with self.assertRaises(Timeout), FileLock( + first.paths.lease(run_id), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + with FileLock(first.paths.coordination_lock(), timeout=0, fallback_to_soft=False, + preserve_lock_file=True): + pass + + ConcurrentRunner.release.set() + await asyncio.gather(*tasks) + for run_id in ("run-one", "run-two"): + with FileLock(first.paths.lease(run_id), timeout=0, fallback_to_soft=False, + preserve_lock_file=True): + pass + + async def test_session_reuses_one_lease_across_executors_and_blocks_clean(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner()) + graphs = tuple( + Graph((current,), (current.name,), {"cpu": 1}) + for current in (node("discovery"), node("final")) + ) + + async with runtime.session(): + self.assertEqual(runtime.owned_run_id, RUN_ID) + with self.assertRaisesRegex(RuntimeError, "already active"): + async with runtime.session(): + pass + await runtime.executor(graphs[0]).run() + with self.assertRaises(CleanError): + await asyncio.to_thread(clean, repository) + await runtime.executor(graphs[1]).run() + with self.assertRaises(Timeout), FileLock( + runtime.paths.lease(RUN_ID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + + self.assertIsNone(runtime.owned_run_id) + with FileLock( + runtime.paths.lease(RUN_ID), timeout=0, fallback_to_soft=False, + preserve_lock_file=True, + ): + pass + + async def test_nonzero_exit_leaves_entry_incomplete_without_marker(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner(23)) + runtime.paths.prepare() + current = node() + + with self.assertRaisesRegex(ProcessFailed, "23.*analyze-renpy.pickle"): + await runtime.run(current) + + cas = runtime.store.paths_for(current) + work = runtime.paths.run_work(RUN_ID) / current.uid + self.assertTrue(cas.entry.is_dir()) + self.assertTrue(work.is_dir()) + self.assertFalse(cas.touch.exists()) + self.assertFalse(runtime.is_complete(current)) + + async def test_cancellation_preserves_scratch(self) -> None: + class CancelledRunner: + async def run(self, _command: Command, *, log) -> int: + raise asyncio.CancelledError + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=CancelledRunner()) + runtime.paths.prepare() + current = node() + work = runtime.paths.run_work(RUN_ID) / current.uid + + with self.assertRaises(asyncio.CancelledError): + await runtime.run(current) + + self.assertTrue(work.is_dir()) + + async def test_reparse_scratch_is_rejected_instead_of_removed(self) -> None: + reparse = False + + class ReplacingRunner: + async def run(self, _command: Command, *, log) -> int: + nonlocal reparse + reparse = True + return 0 + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=ReplacingRunner()) + runtime.paths.prepare() + current = node() + work = runtime.paths.run_work(RUN_ID) / current.uid + + with ( + mock.patch("core.paths._is_reparse", side_effect=lambda path: reparse and path == work), + mock.patch("core.runtime.shutil.rmtree") as remove, + self.assertRaisesRegex(ValueError, "reparse point"), + ): + await runtime.run(current) + + remove.assert_not_called() + self.assertTrue(work.is_dir()) + + async def test_quarantine_survives_successful_scratch_cleanup(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + runtime = BuildRuntime(repository, RUN_ID, process_runner=FakeRunner()) + runtime.paths.prepare() + current = node() + old = runtime.store.paths_for(current) + old.output.mkdir(parents=True) + (old.output / "partial.obj").write_bytes(b"partial") + + await runtime.run(current) + + quarantine = runtime.paths.run_work(RUN_ID) / "quarantine" / old.entry.name + self.assertEqual((quarantine / "out/partial.obj").read_bytes(), b"partial") + self.assertFalse((runtime.paths.run_work(RUN_ID) / current.uid).exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_sanitizer_graph.py b/build/tests/test_sanitizer_graph.py new file mode 100644 index 0000000..83fc85b --- /dev/null +++ b/build/tests/test_sanitizer_graph.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +from contextlib import redirect_stderr +import hashlib +import io +from pathlib import Path +import runpy +import sys +import tempfile +import unittest +from unittest import mock +import xml.etree.ElementTree as ET + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Graph, Node # noqa: E402 +from core.paths import BuildPaths # noqa: E402 +from core.sanitizer import SanitizerError, main as sanitizer_main, require_clean_log # noqa: E402 +from graphs.sanitizer import ( # noqa: E402 + AsanRuntime, + sanitizer_artifact_graph, + sanitizer_dependency_discovery_slice, + sanitizer_graph, +) +from tests import test_instrumented_graph as instrumented_fixture # noqa: E402 + + +def node(name: str, *, inputs: tuple[str, ...] = ()) -> Node: + return Node( + name, + hashlib.md5(name.encode(), usedforsecurity=False).hexdigest(), + "build", + Command(("C:/sdk/build.exe",)), + inputs, + ) + + +class SanitizerGraphTests(unittest.TestCase): + def fixture( + self, + root: Path, + selections: tuple[tuple[str, str], ...] = ( + ("asan", "x86"), ("asan", "x64"), ("ubsan", "x64") + ), + ) -> tuple[Path, Graph, tuple[AsanRuntime, ...], Path]: + repository = root / "repo" + repository.mkdir() + nodes = [] + for sanitizer, architecture in selections: + restore_name = ( + f"restore-vcpkg-asan-{architecture}" + if sanitizer == "asan" + else f"restore-vcpkg-{architecture}" + ) + restore = node(restore_name) + discovery = node( + f"discover-{sanitizer}-{architecture}", inputs=(restore.name,) + ) + nodes.extend((restore, discovery)) + for name, filename in ( + ("renpy", "renpy.so"), + ("rpgmaker", "rpgmaker.so"), + ("zanzarah", "zanzarah.so"), + ("tests", "tests.exe"), + ): + producer = node( + f"build-{name}-{architecture}-{sanitizer}", + inputs=(discovery.name,), + ) + nodes.append(producer) + upstream = Graph( + tuple(nodes), tuple(current.name for current in nodes[1:]), {"build": 8} + ) + tools = root / "tools" + tools.mkdir() + pwsh = tools / "pwsh.exe" + pwsh.touch() + runtimes = [] + for architecture, filename in ( + ("x86", "clang_rt.asan_dynamic-i386.dll"), + ("x64", "clang_rt.asan_dynamic-x86_64.dll"), + ): + if ("asan", architecture) in selections: + path = tools / filename + path.touch() + runtimes.append( + AsanRuntime(architecture, path, {"sha256": f"runtime-{architecture}"}) + ) + return repository, upstream, tuple(runtimes), pwsh + + def build(self, root: Path, **options: object) -> Graph: + selections = options.pop( + "selections", (("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")) + ) + repository, upstream, runtimes, pwsh = self.fixture( + root, selections # type: ignore[arg-type] + ) + return sanitizer_artifact_graph( + repository, + upstream, + selections=selections, # type: ignore[arg-type] + pwsh=pwsh, + pwsh_identity={"version": options.pop("pwsh_version", "7.5")}, + asan_runtimes=runtimes, + **options, + ) + + def test_asan_and_ubsan_build_adapters_create_independent_shard_gates(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + graph = self.build(root, test_shards=2, jobs=6) + paths = BuildPaths(root / "repo") + + self.assertEqual( + graph.pools, + {"build": 8, "sanitizer-shard": 6, "sanitizer-gate": 6}, + ) + self.assertEqual( + graph.targets, + ( + "asan-gate-x64-0", "asan-gate-x64-1", + "asan-gate-x86-0", "asan-gate-x86-1", + "ubsan-gate-x64-0", "ubsan-gate-x64-1", + ), + ) + self.assertEqual(len(graph.nodes), 30) + for sanitizer, architecture in ( + ("asan", "x64"), ("asan", "x86"), ("ubsan", "x64") + ): + builds = tuple( + graph.node(f"build-{name}-{architecture}-{sanitizer}") + for name in ("renpy", "rpgmaker", "zanzarah", "tests") + ) + for index in range(2): + shard = graph.node(f"{sanitizer}-test-{architecture}-{index}") + gate = graph.node(f"{sanitizer}-gate-{architecture}-{index}") + self.assertEqual(shard.inputs, tuple(item.name for item in builds)) + self.assertEqual(gate.inputs, (shard.name,)) + self.assertEqual((shard.pool, gate.pool), ("sanitizer-shard", "sanitizer-gate")) + self.assertEqual( + gate.command.argv[1:], + ( + "-m", "core.sanitizer", "gate", sanitizer, + str(paths.cas(shard.uid).log), + ), + ) + self.assertEqual( + tuple((item.id, item.kind, item.media_type, item.relative_path) + for item in shard.results), + (( + f"reports/sanitizers/{sanitizer}/{architecture}/shard-{index}.xml", + "sanitizer", "application/xml", "tests.xml", + ),), + ) + self.assertEqual(gate.results, ()) + script = shard.command.stdin.decode("utf-8") + self.assertIn("Invoke-Checked", script) + self.assertIn("'--shard-count'", script) + self.assertIn("'2'", script) + self.assertIn("'--shard-index'", script) + self.assertIn(f"'{index}'", script) + for binary in ("renpy.so", "rpgmaker.so", "zanzarah.so", "tests.exe"): + self.assertIn(binary, script) + if sanitizer == "asan": + self.assertIn("ASAN_OPTIONS", script) + self.assertIn("halt_on_error=1:alloc_dealloc_mismatch=1", script) + self.assertIn("clang_rt.asan_dynamic-", script) + self.assertNotIn("UBSAN_OPTIONS", script) + else: + self.assertIn("UBSAN_OPTIONS", script) + self.assertIn("halt_on_error=1:print_stacktrace=1", script) + self.assertNotIn("clang_rt.asan_dynamic-", script) + + def test_runtime_and_pwsh_identities_have_narrow_invalidation_partitions(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, runtimes, pwsh = self.fixture(root) + + def build( + pwsh_version: str = "7.5", runtime_x64: str = "runtime-x64" + ) -> Graph: + changed = tuple( + AsanRuntime( + item.architecture, + item.path, + {"sha256": runtime_x64 if item.architecture == "x64" else "runtime-x86"}, + ) + for item in runtimes + ) + return sanitizer_artifact_graph( + repository, + upstream, + selections=(("asan", "x86"), ("asan", "x64"), ("ubsan", "x64")), + pwsh=pwsh, + pwsh_identity={"version": pwsh_version}, + asan_runtimes=changed, + test_shards=2, + ) + + before = build() + runtime_changed = build(runtime_x64="changed") + pwsh_changed = build(pwsh_version="7.6") + + for current in before.nodes: + if "-test-" not in current.name and "-gate-" not in current.name: + continue + runtime_partition = current.name.startswith("asan-") and "-x64-" in current.name + self.assertEqual( + runtime_partition, + current.uid != runtime_changed.node(current.name).uid, + current.name, + ) + self.assertNotEqual(current.uid, pwsh_changed.node(current.name).uid, current.name) + + def test_rejects_invalid_selections_lineage_runtimes_tools_and_pools(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository, upstream, runtimes, pwsh = self.fixture( + root, (("asan", "x64"),) + ) + + def invoke( + *, + source: Graph = upstream, + selections: tuple[tuple[str, str], ...] = (("asan", "x64"),), + selected_runtimes: tuple[AsanRuntime, ...] = runtimes, + pwsh_path: Path = pwsh, + **options: object, + ) -> Graph: + return sanitizer_artifact_graph( + repository, + source, + selections=selections, + pwsh=pwsh_path, + pwsh_identity={"version": "7.5"}, + asan_runtimes=selected_runtimes, + **options, + ) + + for invalid in ( + (), + (("asan", "x64"), ("asan", "x64")), + (("msan", "x64"),), + (("asan", "arm64"),), + (("ubsan", "x86"),), + ): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, "selections"): + invoke(selections=invalid) + + missing = Graph( + tuple(item for item in upstream.nodes if item.name != "build-tests-x64-asan"), + tuple(name for name in upstream.targets if name != "build-tests-x64-asan"), + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "unknown node"): + invoke(source=missing) + + shared = node("detached-shared") + left = node("detached-left", inputs=(shared.name,)) + right = node("detached-right", inputs=(shared.name,)) + detached = node( + "build-renpy-x64-asan", inputs=(left.name, right.name) + ) + detached_source = Graph( + tuple( + detached if current.name == detached.name else current + for current in upstream.nodes + ) + (shared, left, right), + upstream.targets, + upstream.pools, + ) + with self.assertRaisesRegex(ValueError, "restore ancestor"): + invoke(source=detached_source) + + with self.assertRaisesRegex(ValueError, "runtime.*required"): + invoke(selected_runtimes=()) + with self.assertRaisesRegex(ValueError, "duplicate.*runtime"): + invoke(selected_runtimes=runtimes + runtimes) + bad_runtime = AsanRuntime("arm64", runtimes[0].path, {}) + with self.assertRaisesRegex(ValueError, "runtime identity"): + invoke(selected_runtimes=(bad_runtime,)) + bad_name = AsanRuntime("x64", pwsh, {}) + with self.assertRaisesRegex(ValueError, "runtime identity"): + invoke(selected_runtimes=(bad_name,)) + directory_runtime = AsanRuntime("x64", pwsh.parent, {}) + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(selected_runtimes=(directory_runtime,)) + with self.assertRaisesRegex(FileNotFoundError, "not a file"): + invoke(pwsh_path=pwsh.parent) + for options in ({"test_shards": 0}, {"jobs": True}): + with self.subTest(options=options), self.assertRaisesRegex(ValueError, "positive integer"): + invoke(**options) + matching = Graph( + upstream.nodes, upstream.targets, + dict(upstream.pools) | {"sanitizer-shard": 4, "sanitizer-gate": 4}, + ) + self.assertEqual(invoke(source=matching).pools["sanitizer-shard"], 4) + conflict = Graph( + upstream.nodes, upstream.targets, + dict(upstream.pools) | {"sanitizer-shard": 1}, + ) + with self.assertRaisesRegex(ValueError, "conflicting pool"): + invoke(source=conflict) + + def test_complete_graph_discovers_and_builds_all_sanitizer_artifacts_itself(self) -> None: + helper = instrumented_fixture.InstrumentedBuildGraphTests() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + repository = helper.repository(root / "repo") + toolchain = helper.toolchain(root) + llvm_runtime = helper.llvm_runtime(root) + runtime_path = root / "clang_rt.asan_dynamic-x86_64.dll" + runtime_path.touch() + runtimes = ( + AsanRuntime("x64", runtime_path, {"sha256": "asan-x64"}), + ) + selections = (("asan", "x64"), ("ubsan", "x64")) + runtime_identity = {"standalone": "sha256:a", "cxx": "sha256:b"} + discovery = sanitizer_dependency_discovery_slice( + repository, + toolchain, + selections=selections, + llvm_runtime=llvm_runtime, + llvm_runtime_identity=runtime_identity, + jobs=4, + ) + graph = sanitizer_graph( + repository, + toolchain, + discovery=discovery, + manifests=helper.manifests(repository, discovery), + selections=selections, + llvm_runtime=llvm_runtime, + llvm_runtime_identity=runtime_identity, + asan_runtimes=runtimes, + jobs=4, + test_shards=1, + ) + + self.assertEqual(graph.targets, ("asan-gate-x64-0", "ubsan-gate-x64-0")) + self.assertEqual( + len(graph.nodes), + len(discovery.nodes) + len(selections) * (4 + 1 + 1), + ) + for sanitizer in ("asan", "ubsan"): + shard = graph.node(f"{sanitizer}-test-x64-0") + for project in ("renpy", "rpgmaker", "zanzarah", "tests"): + build = graph.node(f"build-{project}-x64-{sanitizer}") + self.assertIn( + f"'/p:Configuration={'ASan' if sanitizer == 'asan' else 'UBSan'}'", + build.command.stdin.decode(), + ) + self.assertEqual(shard.inputs.count(build.name), 1) + + def test_sanitizer_runtime_is_test_only_and_release_remains_static_mt(self) -> None: + root = ET.parse(BUILD_ROOT / "ObserverProject.props").getroot() + namespace = "{http://schemas.microsoft.com/developer/msbuild/2003}" + runtime_libraries = root.findall(f".//{namespace}RuntimeLibrary") + release = next( + item for item in runtime_libraries if "$(Configuration)' == 'Release" in item.get("Condition", "") + ) + self.assertEqual(release.text, "MultiThreaded") + + +class SanitizerGateTests(unittest.TestCase): + def test_clean_logs_pass_and_findings_fail_for_each_runtime(self) -> None: + require_clean_log("asan", "All tests passed (42 assertions)\n") + require_clean_log("ubsan", "All tests passed (42 assertions)\n") + for sanitizer, finding in ( + ("asan", "ERROR: AddressSanitizer: heap-use-after-free"), + ("asan", "AddressSanitizer:DEADLYSIGNAL"), + ("asan", "SUMMARY: AddressSanitizer: double-free"), + ("ubsan", "foo.cpp:3: runtime error: signed integer overflow"), + ("ubsan", "UndefinedBehaviorSanitizer:DEADLYSIGNAL"), + ("ubsan", "SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior"), + ): + with self.subTest(sanitizer=sanitizer, finding=finding), self.assertRaisesRegex( + SanitizerError, "finding" + ): + require_clean_log(sanitizer, finding) + with self.assertRaisesRegex(SanitizerError, "unsupported"): + require_clean_log("msan", "clean") + + def test_cli_has_no_relaxation_switch_and_module_entrypoint(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + log = Path(temporary) / "test.log" + log.write_text("All tests passed\n", encoding="utf-8") + self.assertEqual(sanitizer_main(("gate", "asan", str(log))), 0) + with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + sanitizer_main(("gate", "asan", str(log), "--allow-findings")) + with ( + mock.patch.object(sys, "argv", ["sanitizer.py", "gate", "ubsan", str(log)]), + self.assertWarnsRegex(RuntimeWarning, "core.sanitizer"), + self.assertRaises(SystemExit) as raised, + ): + runpy.run_module("core.sanitizer", run_name="__main__") + self.assertEqual(raised.exception.code, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_sarif.py b/build/tests/test_sarif.py new file mode 100644 index 0000000..39a43ef --- /dev/null +++ b/build/tests/test_sarif.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import contextlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from core.sarif import ( # noqa: E402 + SarifError, + SarifFindingsError, + clang_tidy_to_sarif, + merge_sarif, + normalize_msvc, + require_clean, +) +from core import sarif # noqa: E402 + + +def write_json(path: Path, document: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(document), encoding="utf-8") + + +class SarifTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def test_clang_tidy_conversion_is_confined_deduplicated_and_deterministic(self) -> None: + repository = self.root / "repo" + source = repository / "src" / "unit.cpp" + other_source = repository / "src" / "other.cpp" + outside = self.root / "repo-sibling" / "outside.cpp" + for path in (source, other_source, outside): + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + logs = self.root / "objects" + first = logs / "z" / "z.ClangTidy.log" + second = logs / "a" / "a.ClangTidy.log" + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + duplicate = f"{source}(9,3): warning: duplicate message [modernize-use-nullptr] [renpy.vcxproj]" + first.write_text( + "\n".join( + ( + duplicate, + f"{outside}(1,1): error: outside [outside-check] [renpy.vcxproj]", + f"{source}(1,1): warning: no rule suffix", + f"{source}(2,5): error: second message [-*, bugprone-sizeof-expression] [renpy.vcxproj]", + ) + ), + encoding="utf-8", + ) + second.write_text( + "\n".join( + ( + f"{other_source}(4,2): warning: first message [alpha-check] [renpy.vcxproj]", + duplicate, + f"{source}(3,1): warning: disabled only [-*] [renpy.vcxproj]", + "ordinary compiler output", + ) + ), + encoding="utf-8", + ) + + output = self.root / "reports" / "tidy.sarif" + repeat = self.root / "reports" / "repeat.sarif" + automation_id = "clang-tidy/x64/renpy/pickle/" + clang_tidy_to_sarif(repository, logs, output, automation_id) + clang_tidy_to_sarif(repository, logs, repeat, automation_id) + + self.assertEqual(output.read_bytes(), repeat.read_bytes()) + document = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual("2.1.0", document["version"]) + self.assertEqual(automation_id, document["runs"][0]["automationDetails"]["id"]) + driver = document["runs"][0]["tool"]["driver"] + self.assertEqual( + ["alpha-check", "bugprone-sizeof-expression", "modernize-use-nullptr"], + [rule["id"] for rule in driver["rules"]], + ) + results = document["runs"][0]["results"] + self.assertEqual(3, len(results)) + self.assertEqual( + [ + ("src/other.cpp", 4, "alpha-check", "warning", "first message"), + ("src/unit.cpp", 2, "bugprone-sizeof-expression", "error", "second message"), + ("src/unit.cpp", 9, "modernize-use-nullptr", "warning", "duplicate message"), + ], + [ + ( + result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"], + result["locations"][0]["physicalLocation"]["region"]["startLine"], + result["ruleId"], + result["level"], + result["message"]["text"], + ) + for result in results + ], + ) + + def test_clang_tidy_missing_log_tree_produces_an_empty_run(self) -> None: + repository = self.root / "repo" + repository.mkdir() + output = self.root / "empty.sarif" + + clang_tidy_to_sarif(repository, self.root / "missing", output, "tidy/exact/") + + run = json.loads(output.read_text(encoding="utf-8"))["runs"][0] + self.assertEqual([], run["results"]) + self.assertEqual([], run["tool"]["driver"]["rules"]) + + def test_msvc_normalization_retains_document_and_assigns_stable_run_ids(self) -> None: + source = self.root / "raw.sarif" + output = self.root / "normalized.sarif" + original = { + "version": "2.1.0", + "$schema": "original-schema", + "inlineExternalProperties": [{"guid": "kept"}], + "runs": [ + {"automationDetails": {"id": "unstable", "description": {"text": "kept"}}, "results": []}, + {"automationDetails": "invalid but replaceable", "properties": {"kept": True}}, + ], + } + write_json(source, original) + + normalize_msvc(source, output, "msvc-analyze/x64/renpy/pickle/") + + normalized = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual("original-schema", normalized["$schema"]) + self.assertEqual(original["inlineExternalProperties"], normalized["inlineExternalProperties"]) + self.assertEqual( + [ + "msvc-analyze/x64/renpy/pickle/run-1/", + "msvc-analyze/x64/renpy/pickle/run-2/", + ], + [run["automationDetails"]["id"] for run in normalized["runs"]], + ) + self.assertEqual({"text": "kept"}, normalized["runs"][0]["automationDetails"]["description"]) + self.assertEqual({"kept": True}, normalized["runs"][1]["properties"]) + + single_source = self.root / "single.sarif" + single_output = self.root / "single-normalized.sarif" + write_json(single_source, {"version": "2.1.0", "runs": [{"results": []}]}) + normalize_msvc(single_source, single_output, "caller-supplied-exact-id") + self.assertEqual( + "caller-supplied-exact-id", + json.loads(single_output.read_text(encoding="utf-8"))["runs"][0]["automationDetails"]["id"], + ) + + def test_normalization_rejects_non_21_documents_or_invalid_runs(self) -> None: + source = self.root / "raw.sarif" + output = self.root / "normalized.sarif" + for document in ( + {"version": "2.0.0", "runs": [{}]}, + {"version": "2.1.0", "runs": []}, + {"version": "2.1.0", "runs": ["not an object"]}, + ): + with self.subTest(document=document): + write_json(source, document) + with self.assertRaises(SarifError): + normalize_msvc(source, output, "id") + + def test_merge_sorts_runs_by_identity_and_is_deterministic(self) -> None: + first = self.root / "first.sarif" + second = self.root / "second.sarif" + write_json(first, {"version": "2.1.0", "runs": [{"automationDetails": {"id": "z/"}, "value": 2}]}) + write_json( + second, + { + "version": "2.1.0", + "runs": [ + {"automationDetails": {"id": "m/"}, "value": 1}, + {"automationDetails": {"id": "a/"}, "value": 0}, + ], + }, + ) + output = self.root / "merged.sarif" + reverse = self.root / "merged-reverse.sarif" + + merge_sarif((first, second), output) + merge_sarif((second, first), reverse) + + self.assertEqual(output.read_bytes(), reverse.read_bytes()) + merged = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(["a/", "m/", "z/"], [run["automationDetails"]["id"] for run in merged["runs"]]) + self.assertEqual([0, 1, 2], [run["value"] for run in merged["runs"]]) + + def test_merge_rejects_missing_or_duplicate_identity_and_no_inputs(self) -> None: + output = self.root / "merged.sarif" + missing = self.root / "missing-id.sarif" + duplicate = self.root / "duplicate.sarif" + write_json(missing, {"version": "2.1.0", "runs": [{"automationDetails": {}}]}) + write_json( + duplicate, + { + "version": "2.1.0", + "runs": [ + {"automationDetails": {"id": "same/"}}, + {"automationDetails": {"id": "same/"}}, + ], + }, + ) + + for inputs in ((), (missing,), (duplicate,)): + with self.subTest(inputs=inputs), self.assertRaises(SarifError): + merge_sarif(inputs, output) + + def test_gate_ignores_non_findings_and_rejects_warnings_and_errors(self) -> None: + clean = self.root / "clean.sarif" + warning = self.root / "warning.sarif" + write_json( + clean, + { + "version": "2.1.0", + "runs": [ + { + "automationDetails": {"id": "clean/"}, + "results": [{"level": "note"}, {"level": "none"}], + } + ], + }, + ) + write_json( + warning, + { + "version": "2.1.0", + "runs": [ + { + "automationDetails": {"id": "findings/"}, + "results": [{"level": "warning"}, {"level": "error"}, {"level": "note"}], + } + ], + }, + ) + + self.assertIsNone(require_clean((clean,))) + with self.assertRaisesRegex(SarifFindingsError, "2 warning/error finding"): + require_clean((clean, warning)) + + def test_cli_dispatches_every_command_into_observer_output_directory(self) -> None: + output = self.root / "out" + output.mkdir() + source = self.root / "raw.sarif" + logs = self.root / "logs" + repository = self.root / "repo" + environment = {"OBSERVER_OUT_DIR": str(output)} + + with mock.patch.dict("os.environ", environment, clear=True): + with mock.patch.object(sarif, "normalize_msvc") as operation: + self.assertEqual( + 0, + sarif.main(("normalize-msvc", str(source), "msvc/id/", "--output-name", "renpy.sarif")), + ) + operation.assert_called_once_with(source, output / "renpy.sarif", "msvc/id/") + + with mock.patch.object(sarif, "clang_tidy_to_sarif") as operation: + self.assertEqual( + 0, + sarif.main(("convert-tidy", str(repository), str(logs), "tidy/id/")), + ) + operation.assert_called_once_with(repository, logs, output / "renpy.sarif", "tidy/id/") + + other = self.root / "other.sarif" + with mock.patch.object(sarif, "merge_sarif") as operation: + self.assertEqual(0, sarif.main(("merge", str(source), str(other)))) + operation.assert_called_once_with([source, other], output / "analysis.sarif") + + with mock.patch.object(sarif, "require_clean") as operation: + self.assertEqual(0, sarif.main(("gate", str(source)))) + operation.assert_called_once_with((source,)) + self.assertEqual([], list(output.iterdir())) + + def test_cli_requires_existing_output_directory_and_confines_output_name(self) -> None: + source = self.root / "raw.sarif" + stderr = io.StringIO() + with mock.patch.dict("os.environ", {}, clear=True), contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit): + sarif.main(("gate", str(source))) + self.assertIn("OBSERVER_OUT_DIR", stderr.getvalue()) + + missing = self.root / "missing" + stderr = io.StringIO() + with mock.patch.dict("os.environ", {"OBSERVER_OUT_DIR": str(missing)}, clear=True), contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit): + sarif.main(("gate", str(source))) + self.assertIn("existing directory", stderr.getvalue()) + + output = self.root / "out" + output.mkdir() + stderr = io.StringIO() + with mock.patch.dict("os.environ", {"OBSERVER_OUT_DIR": str(output)}, clear=True), contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit): + sarif.main(("normalize-msvc", str(source), "id", "--output-name", "../escape.sarif")) + self.assertIn("confined", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_sign.py b/build/tests/test_sign.py new file mode 100644 index 0000000..00fcedc --- /dev/null +++ b/build/tests/test_sign.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import re +import sys +import unittest +from pathlib import Path + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.sign import content_uid # noqa: E402 + + +class ContentUidTests(unittest.TestCase): + def fields(self) -> dict[str, object]: + return { + "recipe": '{"script":"build"}\n', + "inputs": { + "src/archive.cpp": b"archive bytes\x00", + "src/archive.h": b"header bytes\n", + }, + "dependencies": { + "vcpkg": "0123456789abcdef0123456789abcdef", + "headers": "fedcba9876543210fedcba9876543210", + }, + "toolchain": {"msvc": "19.44", "sdk": "10.0.26100.0"}, + "config": {"arch": "x64", "flags": ["/MT", "/W4"]}, + } + + def uid(self, **changes: object) -> str: + fields = self.fields() + fields.update(changes) + return content_uid(**fields) # type: ignore[arg-type] + + def test_uid_is_lowercase_md5(self) -> None: + uid = self.uid() + + self.assertRegex(uid, re.compile(r"^[0-9a-f]{32}$")) + + def test_mapping_order_does_not_change_uid(self) -> None: + fields = self.fields() + + self.assertEqual( + content_uid(**fields), # type: ignore[arg-type] + content_uid( + recipe=fields["recipe"], # type: ignore[arg-type] + inputs=dict(reversed(list(fields["inputs"].items()))), # type: ignore[union-attr] + dependencies=dict( + reversed(list(fields["dependencies"].items())) # type: ignore[union-attr] + ), + toolchain={"sdk": "10.0.26100.0", "msvc": "19.44"}, + config={"flags": ["/MT", "/W4"], "arch": "x64"}, + ), + ) + + def test_every_signed_field_changes_uid(self) -> None: + original = self.uid() + changes = [ + {"recipe": '{"script":"test"}\n'}, + {"inputs": {"src/other.cpp": b"archive bytes\x00", "src/archive.h": b"header bytes\n"}}, + {"inputs": {"src/archive.cpp": b"changed\x00", "src/archive.h": b"header bytes\n"}}, + {"dependencies": {"other": "0123456789abcdef0123456789abcdef", "headers": "fedcba9876543210fedcba9876543210"}}, + {"dependencies": {"vcpkg": "11111111111111111111111111111111", "headers": "fedcba9876543210fedcba9876543210"}}, + {"toolchain": {"compiler": "19.44", "sdk": "10.0.26100.0"}}, + {"toolchain": {"msvc": "19.45", "sdk": "10.0.26100.0"}}, + {"config": {"architecture": "x64", "flags": ["/MT", "/W4"]}}, + {"config": {"arch": "ARM64", "flags": ["/MT", "/W4"]}}, + {"config": {"arch": "x64", "flags": ["/W4", "/MT"]}}, + ] + + for change in changes: + with self.subTest(change=change): + self.assertNotEqual(original, self.uid(**change)) + + def test_binary_inputs_are_framed_without_concatenation_ambiguity(self) -> None: + common = { + "recipe": b"recipe", + "dependencies": {}, + "toolchain": {}, + "config": {}, + } + + self.assertNotEqual( + content_uid(inputs={"a": b"bc"}, **common), + content_uid(inputs={"ab": b"c"}, **common), + ) + + def test_recipe_text_is_signed_as_exact_utf8_bytes(self) -> None: + fields = self.fields() + text_uid = content_uid(**fields) # type: ignore[arg-type] + fields["recipe"] = str(fields["recipe"]).encode("utf-8") + + self.assertEqual(text_uid, content_uid(**fields)) # type: ignore[arg-type] + + def test_noncanonical_inputs_and_dependencies_are_rejected(self) -> None: + valid = self.fields() + invalid_fields = [ + {"recipe": object()}, + {"inputs": {1: b"bytes"}}, + {"inputs": {"source": "text"}}, + {"dependencies": {1: "0123456789abcdef0123456789abcdef"}}, + {"dependencies": {"dep": 1}}, + {"dependencies": {"dep": "not-an-md5"}}, + ] + + for change in invalid_fields: + fields = dict(valid) + fields.update(change) + with self.subTest(change=change), self.assertRaises((TypeError, ValueError)): + content_uid(**fields) # type: ignore[arg-type] + + def test_identity_is_json_and_rejects_ambiguous_values(self) -> None: + valid = self.fields() + valid["toolchain"] = { + "enabled": True, + "generation": 1, + "ratio": 0.5, + "optional": None, + "tuple": ("a", "b"), + } + self.assertRegex(content_uid(**valid), re.compile(r"^[0-9a-f]{32}$")) # type: ignore[arg-type] + + for toolchain in ( + {1: "value"}, + {"nested": {1: "ambiguous"}}, + {"invalid": object()}, + {"nan": float("nan")}, + ): + fields = self.fields() + fields["toolchain"] = toolchain + with self.subTest(toolchain=toolchain), self.assertRaises((TypeError, ValueError)): + content_uid(**fields) # type: ignore[arg-type] + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_source_graph.py b/build/tests/test_source_graph.py new file mode 100644 index 0000000..9778825 --- /dev/null +++ b/build/tests/test_source_graph.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import shutil +import subprocess +import sys +import unittest +from dataclasses import replace +from pathlib import Path + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = BUILD_ROOT.parent +sys.path.insert(0, str(BUILD_ROOT)) + +from graphs.source import ( # noqa: E402 + SourceTools, + _repository_files, + architecture_source_checks, + common_source_checks, + source_checks, +) +from core.paths import BuildPaths # noqa: E402 + + +class SourceGraphTests(unittest.TestCase): + def tools(self) -> SourceTools: + return SourceTools( + pwsh=Path(r"C:\tools\pwsh.exe"), + clang_format=Path(r"C:\tools\clang-format.exe"), + cppcheck=Path(r"C:\tools\cppcheck.exe"), + psscriptanalyzer=Path(r"C:\modules\PSScriptAnalyzer.psd1"), + vcpkg_root=Path(r"C:\tools\vcpkg"), + environment=(("PATH", r"C:\tools"),), + identity=( + ("clang_format", r"C:\tools\clang-format.exe"), + ("clang_format_version", "21.1"), + ("cppcheck", r"C:\tools\cppcheck.exe"), + ("cppcheck_version", "2.18"), + ("psscriptanalyzer", r"C:\modules\PSScriptAnalyzer.psd1"), + ("psscriptanalyzer_version", "1.24"), + ("pwsh", r"C:\tools\pwsh.exe"), + ("pwsh_version", "7.5"), + ("vcpkg_root", r"C:\tools\vcpkg"), + ), + ) + + def graph(self): + return source_checks(REPOSITORY, self.tools(), jobs=7) + + def test_contract_inventory_excludes_transient_build_state(self) -> None: + import tempfile + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + source = repository / "src/file.cpp" + source.parent.mkdir() + source.touch() + for relative in (".coverage", "build/.coverage.agent"): + path = repository / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + files = _repository_files(repository) + + self.assertEqual(files, (source,)) + + def test_every_independent_source_check_is_a_demand_node(self) -> None: + graph = self.graph() + cpp_sources = sorted( + path + for path in (REPOSITORY / "src").rglob("*") + if path.is_file() and path.suffix in {".cpp", ".h", ".hpp"} + ) + powershell_sources = [REPOSITORY / "build.ps1"] + sorted( + path + for path in (REPOSITORY / "build").rglob("*") + if ( + path.is_file() + and path.suffix in {".ps1", ".psm1"} + and not path.is_relative_to(REPOSITORY / "build/templates") + ) + ) + contracts = sorted((REPOSITORY / "build/tests").glob("*.Tests.ps1")) + + self.assertEqual( + len(graph.nodes), + len(cpp_sources) + len(powershell_sources) + len(contracts) + 8, + ) + self.assertEqual(graph.targets, ("source-checks",)) + self.assertEqual(dict(graph.pools), {"restore": 1, "slot": 7}) + self.assertEqual( + len([node for node in graph.nodes if node.name.startswith("format-")]), + len(cpp_sources), + ) + self.assertEqual( + len([node for node in graph.nodes if node.name.startswith("pssa-")]), + len(powershell_sources), + ) + self.assertFalse(any(node.name.startswith("pssa-build.templates.") for node in graph.nodes)) + self.assertEqual( + len([node for node in graph.nodes if node.name.startswith("contract-")]), + len(contracts), + ) + self.assertEqual( + {node.name for node in graph.nodes if node.name.startswith("cppcheck-")}, + {"cppcheck-x86", "cppcheck-x64", "cppcheck-arm64"}, + ) + self.assertEqual( + {node.name for node in graph.nodes if node.name.startswith("restore-vcpkg-")}, + {"restore-vcpkg-x86", "restore-vcpkg-x64", "restore-vcpkg-arm64"}, + ) + for architecture in ("x86", "x64", "arm64"): + self.assertEqual( + graph.node(f"cppcheck-{architecture}").inputs, + (f"restore-vcpkg-{architecture}",), + ) + self.assertTrue( + all( + not node.inputs + for node in graph.nodes + if node.name.startswith(("format-", "pssa-", "contract-")) + ) + ) + + merged = graph.node("merge-source-findings") + pssa_names = { + node.name for node in graph.nodes if node.name.startswith("pssa-") + } + self.assertEqual( + set(merged.inputs), + {"cppcheck-x86", "cppcheck-x64", "cppcheck-arm64"} | pssa_names, + ) + gate = graph.node("source-checks") + direct = { + node.name + for node in graph.nodes + if node.name.startswith(("format-", "contract-")) + } + self.assertEqual(set(gate.inputs), direct | {"merge-source-findings"}) + + def test_cppcheck_matrix_contains_only_requested_supported_architectures(self) -> None: + graph = source_checks(REPOSITORY, self.tools(), jobs=7, architectures=("arm64", "x64")) + + self.assertEqual( + {node.name for node in graph.nodes if node.name.startswith("cppcheck-")}, + {"cppcheck-arm64", "cppcheck-x64"}, + ) + self.assertEqual( + {node.name for node in graph.nodes if node.name.startswith("restore-vcpkg-")}, + {"restore-vcpkg-arm64", "restore-vcpkg-x64"}, + ) + self.assertNotIn("cppcheck-x86", graph.node("merge-source-findings").inputs) + for architectures in ((), ("mips",), ("x64", "x64")): + with self.subTest(architectures=architectures), self.assertRaisesRegex(ValueError, "architectures"): + source_checks(REPOSITORY, self.tools(), architectures=architectures) + + def test_ci_slices_do_not_duplicate_common_and_architecture_checks(self) -> None: + common = common_source_checks(REPOSITORY, self.tools(), jobs=7) + architecture = architecture_source_checks( + REPOSITORY, self.tools(), ("arm64",), jobs=7 + ) + + self.assertEqual(common.targets, ("source-checks",)) + self.assertFalse(any(node.name.startswith("cppcheck-") for node in common.nodes)) + self.assertFalse(any(node.name.startswith("restore-vcpkg-") for node in common.nodes)) + self.assertTrue(any(node.name.startswith("format-") for node in common.nodes)) + self.assertTrue(any(node.name.startswith("pssa-") for node in common.nodes)) + self.assertTrue(any(node.name.startswith("contract-") for node in common.nodes)) + + self.assertEqual(architecture.targets, ("cppcheck-checks-arm64",)) + self.assertEqual( + {node.name for node in architecture.nodes}, + { + "restore-vcpkg-arm64", + "cppcheck-arm64", + "merge-cppcheck-findings-arm64", + "cppcheck-checks-arm64", + }, + ) + self.assertEqual(dict(architecture.pools), {"restore": 1, "slot": 7}) + common_producer, common_result = common.result( + "reports/sarif/source/analysis.sarif" + ) + self.assertEqual(common_producer.name, "merge-source-findings") + self.assertEqual(common_result.relative_path, "analysis.sarif") + cppcheck_producer, cppcheck_result = architecture.result( + "reports/sarif/arm64/cppcheck.sarif" + ) + self.assertEqual(cppcheck_producer.name, "merge-cppcheck-findings-arm64") + self.assertEqual(cppcheck_result.relative_path, "analysis.sarif") + + def test_templates_keep_tool_paths_literal_and_cppcheck_findings_publishable(self) -> None: + graph = self.graph() + + formatted = graph.node("format-src.modules.renpy.pickle.cpp") + self.assertEqual(formatted.command.argv[0], r"C:\tools\pwsh.exe") + format_script = formatted.command.stdin.decode() + self.assertIn("Invoke-Checked 'C:\\tools\\clang-format.exe'", format_script) + self.assertIn("'--dry-run'", format_script) + self.assertIn("'--Werror'", format_script) + self.assertIn(str(REPOSITORY / "src/modules/renpy/pickle.cpp"), format_script) + + cppcheck = graph.node("cppcheck-x86") + cppcheck_script = cppcheck.command.stdin.decode() + restore = graph.node("restore-vcpkg-x86") + include_dir = ( + BuildPaths(REPOSITORY).cas(restore.uid).output + / "observer-x86-windows-static/include" + ) + self.assertIn("Invoke-Checked 'C:\\tools\\cppcheck.exe'", cppcheck_script) + self.assertIn("'--platform=win32W'", cppcheck_script) + self.assertIn("'-D_M_IX86=600'", cppcheck_script) + self.assertIn(f"'-I{include_dir}'", cppcheck_script) + self.assertNotIn(f"--suppress=*:{include_dir}", cppcheck_script) + self.assertIn( + "'--suppress=*:*\\observer-x86-windows-static\\include\\*'", + cppcheck_script, + ) + self.assertNotIn("*-restore-vcpkg-*", cppcheck_script) + self.assertIn('"--output-file=$outDir\\cppcheck.sarif"', cppcheck_script) + self.assertNotIn("--error-exitcode", cppcheck_script) + self.assertIn("Cppcheck did not produce cppcheck.sarif", cppcheck_script) + self.assertIn("'cppcheck/x86/' + \"$index/\"", cppcheck_script) + + pssa = graph.node("pssa-build.ps1") + pssa_script = pssa.command.stdin.decode() + self.assertIn("Import-Module 'C:\\modules\\PSScriptAnalyzer.psd1'", pssa_script) + self.assertIn(str(REPOSITORY / "build.ps1"), pssa_script) + self.assertIn(str(REPOSITORY / "build/PSScriptAnalyzerSettings.psd1"), pssa_script) + self.assertIn('"$outDir\\psscriptanalyzer.sarif"', pssa_script) + self.assertNotIn("PSScriptAnalyzer reported", pssa_script) + self.assertIn("'psscriptanalyzer/build.ps1/'", pssa_script) + + contracts = sorted((REPOSITORY / "build/tests").glob("*.Tests.ps1")) + self.assertTrue(contracts) + for test in contracts: + relative = test.relative_to(REPOSITORY).as_posix() + contract = graph.node(f"contract-{relative.lower().replace('/', '.')}") + self.assertEqual(contract.command.argv[0], r"C:\tools\pwsh.exe") + self.assertIn(str(test), contract.command.stdin.decode()) + + merge = graph.node("merge-source-findings") + self.assertEqual(merge.command.argv[1:4], ("-m", "core.sarif", "merge")) + gate = graph.node("source-checks") + self.assertEqual(gate.command.argv[1:4], ("-m", "core.sarif", "gate")) + + def test_tool_identity_changes_only_affected_source_check_branch(self) -> None: + before = self.graph() + identity = dict(self.tools().identity) + identity["cppcheck_version"] = "2.19" + after = source_checks( + REPOSITORY, + replace(self.tools(), identity=tuple(identity.items())), + jobs=7, + ) + + self.assertNotEqual( + before.node("cppcheck-x64").uid, + after.node("cppcheck-x64").uid, + ) + for name in ( + "format-src.modules.renpy.pickle.cpp", + "pssa-build.ps1", + "restore-vcpkg-x64", + ): + self.assertEqual(before.node(name).uid, after.node(name).uid) + + identity = dict(self.tools().identity) + identity["vcpkg_root"] = r"C:\new-vcpkg" + moved_restore = source_checks( + REPOSITORY, + replace( + self.tools(), + vcpkg_root=Path(r"C:\new-vcpkg"), + identity=tuple(identity.items()), + ), + jobs=7, + ) + self.assertNotEqual( + before.node("restore-vcpkg-x64").uid, + moved_restore.node("restore-vcpkg-x64").uid, + ) + self.assertNotEqual( + before.node("cppcheck-x64").uid, + moved_restore.node("cppcheck-x64").uid, + ) + for name in ( + "format-src.modules.renpy.pickle.cpp", + "pssa-build.ps1", + ): + self.assertEqual(before.node(name).uid, moved_restore.node(name).uid) + + def test_rendered_powershell_is_parseable(self) -> None: + graph = self.graph() + scripts = "\n".join( + node.command.stdin.decode() + for node in graph.nodes + if node.command.argv[0] == r"C:\tools\pwsh.exe" + ) + parser = """ +$tokens = $null +$errors = $null +[System.Management.Automation.Language.Parser]::ParseInput( + [Console]::In.ReadToEnd(), [ref]$tokens, [ref]$errors) | Out-Null +if ($errors.Count -ne 0) { $errors | Out-String | Write-Error; exit 1 } +""" + result = subprocess.run( + [ + shutil.which("pwsh"), + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + parser, + ], + input=scripts, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_source_tools.py b/build/tests/test_source_tools.py new file mode 100644 index 0000000..1a93b56 --- /dev/null +++ b/build/tests/test_source_tools.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from dataclasses import dataclass +from pathlib import Path +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.source_tools import discover_source_tools # noqa: E402 + + +def executable(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return path + + +@dataclass(frozen=True) +class FakeToolchain: + llvm_dir: Path + pwsh: Path + vcpkg_root: Path + environment: tuple[tuple[str, str], ...] + + +class SourceToolDiscoveryTests(unittest.TestCase): + def test_discovers_exact_paths_versions_and_preserves_runtime_environment(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + clang_format = executable(root / "llvm/bin/clang-format.exe") + pwsh = executable(root / "PowerShell/pwsh.exe") + cppcheck = executable(root / "Cppcheck/cppcheck.exe") + pssa = executable(root / "Modules/PSScriptAnalyzer/PSScriptAnalyzer.psd1") + vcpkg_root = root / "vcpkg" + vcpkg_root.mkdir() + toolchain = FakeToolchain( + root / "llvm", + pwsh, + vcpkg_root, + (("LIB", "sdk"),), + ) + responses = ( + "PowerShell 7.5.2", + "clang-format version 21.1.0", + "Cppcheck 2.18.0", + json.dumps({"Path": str(pssa), "Version": "1.24.0"}), + ) + + with ( + mock.patch("core.source_tools.shutil.which", return_value=str(cppcheck)) as which, + mock.patch("core.source_tools._output", side_effect=responses) as output, + ): + tools = discover_source_tools(toolchain) + + self.assertEqual(tools.pwsh, pwsh.resolve()) + self.assertEqual(tools.clang_format, clang_format.resolve()) + self.assertEqual(tools.cppcheck, cppcheck.resolve()) + self.assertEqual(tools.psscriptanalyzer, pssa.resolve()) + self.assertEqual(tools.vcpkg_root, vcpkg_root.resolve()) + self.assertEqual(tools.environment, (("LIB", "sdk"),)) + self.assertEqual( + dict(tools.identity), + { + "clang_format": str(clang_format.resolve()), + "clang_format_version": "clang-format version 21.1.0", + "cppcheck": str(cppcheck.resolve()), + "cppcheck_version": "Cppcheck 2.18.0", + "psscriptanalyzer": str(pssa.resolve()), + "psscriptanalyzer_version": "1.24.0", + "pwsh": str(pwsh.resolve()), + "pwsh_version": "PowerShell 7.5.2", + "vcpkg_root": str(vcpkg_root.resolve()), + }, + ) + which.assert_called_once_with("cppcheck.exe") + self.assertEqual(output.call_count, 4) + self.assertEqual(output.call_args_list[0].args[0], [str(pwsh.resolve()), "--version"]) + self.assertEqual( + output.call_args_list[1].args[0], + [str(clang_format.resolve()), "--version"], + ) + self.assertEqual(output.call_args_list[2].args[0], [str(cppcheck.resolve()), "--version"]) + self.assertEqual(output.call_args_list[3].args[0][0], str(pwsh.resolve())) + self.assertIn("Get-Module -ListAvailable PSScriptAnalyzer", output.call_args_list[3].args[0][-1]) + + def test_missing_cppcheck_is_reported_without_running_commands(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + toolchain = FakeToolchain( + root / "llvm", + executable(root / "pwsh.exe"), + root / "vcpkg", + (), + ) + executable(root / "llvm/bin/clang-format.exe") + (root / "vcpkg").mkdir() + with ( + mock.patch("core.source_tools.shutil.which", return_value=None), + mock.patch("core.source_tools._output") as output, + self.assertRaisesRegex(FileNotFoundError, "cppcheck.exe"), + ): + discover_source_tools(toolchain) + + output.assert_not_called() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_store.py b/build/tests/test_store.py new file mode 100644 index 0000000..033b592 --- /dev/null +++ b/build/tests/test_store.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import hashlib +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.graph import Command, Node # noqa: E402 +from core.paths import BuildPaths, PathSafetyError # noqa: E402 +from core.store import CasStateError, CasStore # noqa: E402 + + +RUN_ID = "20260801-test" + + +def node(name: str = "analyze-renpy.pickle", uid: str | None = None) -> Node: + return Node( + name=name, + uid=uid or hashlib.md5(name.encode("utf-8"), usedforsecurity=False).hexdigest(), + pool="cpu", + command=Command(("tool",)), + ) + + +class CasStoreTests(unittest.TestCase): + def make_store(self, repository: Path) -> tuple[BuildPaths, CasStore]: + paths = BuildPaths(repository) + paths.prepare() + return paths, CasStore(paths, RUN_ID) + + @staticmethod + def publish_files(paths: BuildPaths, current: Node) -> None: + cas = paths.cas(current.uid) + cas.entry.mkdir() + cas.output.mkdir() + cas.log.write_text("command succeeded\n", encoding="utf-8") + cas.touch.touch() + + def test_node_maps_to_uid_only_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + + cas = store.paths_for(current) + + self.assertEqual( + cas.entry, + paths.cas_root / current.uid, + ) + + def test_readable_name_collision_shares_content_addressed_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + _paths, store = self.make_store(repository) + shared_uid = "0123456789abcdef0123456789abcdef" + + first = store.paths_for(node("first-node", shared_uid)) + second = store.paths_for(node("second-node", shared_uid)) + + self.assertEqual(first, second) + self.assertEqual(first.entry.name, shared_uid) + + def test_complete_entry_is_a_warm_cache_hit(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + self.publish_files(paths, current) + + self.assertTrue(store.is_complete(current)) + + def test_missing_or_malformed_marker_and_missing_outputs_are_cache_misses(self) -> None: + cases = ( + "missing-touch", + "nonempty-touch", + "touch-directory", + "missing-log", + "log-directory", + "missing-output", + "output-file", + ) + for case in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + self.publish_files(paths, current) + cas = paths.cas(current.uid) + + if case == "missing-touch": + cas.touch.unlink() + elif case == "nonempty-touch": + cas.touch.write_bytes(b"not a completion marker") + elif case == "touch-directory": + cas.touch.unlink() + cas.touch.mkdir() + elif case == "missing-log": + cas.log.unlink() + elif case == "log-directory": + cas.log.unlink() + cas.log.mkdir() + elif case == "missing-output": + cas.output.rmdir() + elif case == "output-file": + cas.output.rmdir() + cas.output.touch() + + self.assertFalse(store.is_complete(current)) + + def test_prepare_quarantines_incomplete_entry_inside_current_run(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + old = paths.cas(current.uid) + old.entry.mkdir() + old.output.mkdir() + (old.output / "partial.obj").write_bytes(b"partial") + + prepared = store.prepare_entry(current) + + quarantine = paths.run_work(RUN_ID) / "quarantine" / old.entry.name + self.assertEqual(prepared, paths.cas(current.uid)) + self.assertEqual((quarantine / "out" / "partial.obj").read_bytes(), b"partial") + self.assertTrue(prepared.entry.is_dir()) + self.assertTrue(prepared.output.is_dir()) + self.assertTrue(prepared.log.is_file()) + self.assertFalse(prepared.touch.exists()) + + def test_prepare_resolves_node_paths_once(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + + with mock.patch.object(paths, "cas", wraps=paths.cas) as resolve: + store.prepare_entry(current) + + resolve.assert_called_once_with(current.uid) + + def test_prepare_never_mutates_complete_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + self.publish_files(paths, current) + cas = paths.cas(current.uid) + sentinel = cas.output / "result.bin" + sentinel.write_bytes(b"immutable") + before = { + child.relative_to(cas.entry): (child.stat().st_mtime_ns, child.read_bytes()) + for child in (cas.log, cas.touch, sentinel) + } + + prepared = store.prepare_entry(current) + + after = { + child.relative_to(cas.entry): (child.stat().st_mtime_ns, child.read_bytes()) + for child in (cas.log, cas.touch, sentinel) + } + self.assertEqual(prepared, cas) + self.assertEqual(after, before) + self.assertFalse((paths.run_work(RUN_ID) / "quarantine").exists()) + + def test_prepare_fails_if_incomplete_entry_cannot_be_quarantined(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + cas = paths.cas(current.uid) + cas.entry.mkdir() + cas.output.mkdir() + destination = paths.run_work(RUN_ID) / "quarantine" / cas.entry.name + destination.mkdir(parents=True) + + with self.assertRaisesRegex(CasStateError, "quarantine destination already exists"): + store.prepare_entry(current) + + self.assertTrue(cas.entry.is_dir()) + self.assertTrue(destination.is_dir()) + + def test_prepare_reports_move_failure_without_deleting_or_retrying(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + cas = paths.cas(current.uid) + cas.entry.mkdir() + cas.output.mkdir() + + with mock.patch.object(Path, "rename", side_effect=OSError("locked")) as rename: + with self.assertRaisesRegex(CasStateError, "could not quarantine"): + store.prepare_entry(current) + + rename.assert_called_once() + self.assertTrue(cas.entry.is_dir()) + self.assertFalse( + (paths.run_work(RUN_ID) / "quarantine" / cas.entry.name).exists() + ) + + def test_reparse_component_is_rejected_instead_of_followed(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + regular = BuildPaths(repository) + regular.prepare() + current = node() + self.publish_files(regular, current) + cas = regular.cas(current.uid) + + reparse_paths = {cas.output} + + with mock.patch( + "core.paths._is_reparse", + side_effect=lambda path: path in reparse_paths, + ): + store = CasStore(BuildPaths(repository), RUN_ID) + with self.assertRaisesRegex(PathSafetyError, "reparse point"): + store.is_complete(current) + with self.assertRaisesRegex(PathSafetyError, "reparse point"): + store.prepare_entry(current) + + def test_mark_complete_publishes_zero_byte_marker_after_outputs_exist(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + cas = store.prepare_entry(current) + cas.log.write_text("success\n", encoding="utf-8") + + store.mark_complete(current) + + self.assertTrue(cas.touch.is_file()) + self.assertEqual(cas.touch.stat().st_size, 0) + self.assertTrue(store.is_complete(current)) + + def test_mark_complete_requires_output_and_log_and_never_overwrites_marker(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + paths, store = self.make_store(repository) + current = node() + cas = store.prepare_entry(current) + cas.log.unlink() + + with self.assertRaisesRegex(CasStateError, "log file"): + store.mark_complete(current) + self.assertFalse(cas.touch.exists()) + + cas.log.write_text("success\n", encoding="utf-8") + cas.touch.write_bytes(b"existing") + original_stat = os.lstat(cas.touch) + + with self.assertRaisesRegex(CasStateError, "completion marker already exists"): + store.mark_complete(current) + + self.assertEqual(cas.touch.read_bytes(), b"existing") + self.assertEqual(os.lstat(cas.touch).st_mtime_ns, original_stat.st_mtime_ns) + + def test_mark_complete_reports_exclusive_publication_race(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repo" + repository.mkdir() + _paths, store = self.make_store(repository) + current = node() + cas = store.prepare_entry(current) + + with mock.patch.object( + Path, + "open", + autospec=True, + side_effect=FileExistsError("raced"), + ): + with self.assertRaisesRegex(CasStateError, "marker already exists"): + store.mark_complete(current) + + self.assertFalse(cas.touch.exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_toolchain.py b/build/tests/test_toolchain.py new file mode 100644 index 0000000..b29f31c --- /dev/null +++ b/build/tests/test_toolchain.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.toolchain import _command_environment, discover_msvc_toolchain # noqa: E402 + + +def executable(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return path + + +class MsvcToolchainTests(unittest.TestCase): + def test_command_environment_is_stable_when_canonical_values_are_inherited(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + include = root / "include" + lib = root / "lib" + libpath = root / "references" + for directory in (include, lib, libpath): + directory.mkdir() + + canonical = { + "INCLUDE": str(include), + "LIB": str(lib), + "LIBPATH": str(libpath), + "VCToolsVersion": "14.44.35207", + "VSCMD_VER": "17.14.15", + "WindowsSDKVersion": "10.0.26100.0\\", + } + captured = "\r\n".join( + ( + *(f"{key}={value}" for key, value in canonical.items()), + "Path=toolchain;host", + "__VSCMD_PREINIT_PATH=host", + "GITHUB_SHA=unchanged", + "UNRELATED=unchanged", + ) + ) + inherited = {"GITHUB_SHA": "unchanged", "UNRELATED": "unchanged"} + + with mock.patch.dict(os.environ, inherited, clear=True): + absent = _command_environment(captured) + with mock.patch.dict(os.environ, inherited | canonical, clear=True): + already_equal = _command_environment(captured) + + self.assertEqual(dict(absent), canonical) + self.assertEqual(already_equal, absent) + + def test_discovers_x64_tools_and_canonical_command_environment(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + program_files = root / "Program Files (x86)" + system_root = root / "Windows" + installation = root / "Visual Studio" + vcpkg_root = root / "vcpkg" / "2026.07.29" + lib = root / "SDK" / "Lib" + lib.mkdir(parents=True) + vcpkg_root.mkdir(parents=True) + + vswhere = executable( + program_files + / "Microsoft Visual Studio" + / "Installer" + / "vswhere.exe" + ) + cmd = executable(system_root / "System32" / "cmd.exe") + msbuild = executable( + installation / "MSBuild" / "Current" / "Bin" / "amd64" / "MSBuild.exe" + ) + vsdevcmd = executable(installation / "Common7" / "Tools" / "VsDevCmd.bat") + llvm_dir = installation / "VC" / "Tools" / "Llvm" / "x64" + clang_tidy = executable(llvm_dir / "bin" / "clang-tidy.exe") + pwsh = executable(root / "PowerShell" / "pwsh.exe") + + original = { + "ProgramFiles(x86)": str(program_files), + "SystemRoot": str(system_root), + "VCPKG_ROOT": str(vcpkg_root), + "UNCHANGED": "host", + "Path": "host", + } + command_environment = "\r\n".join( + ( + "=C:=C:\\working", + "Path=first;host", + "PATH=host;second", + "__VSCMD_PREINIT_PATH=host", + f"Lib={lib};{root / 'missing'}", + "VisualStudioVersion=17.0", + "VSCMD_VER=17.14.15", + "VCToolsVersion=14.44.35207", + "WindowsSDKVersion=10.0.26100.0\\", + "UNCHANGED=host", + "", + ) + ) + responses = ( + subprocess.CompletedProcess([], 0, stdout=f"{installation}\r\n", stderr=""), + subprocess.CompletedProcess([], 0, stdout=command_environment, stderr=""), + subprocess.CompletedProcess([], 0, stdout="17.14.51.32402\r\n", stderr=""), + subprocess.CompletedProcess( + [], + 0, + stdout="LLVM tools\r\n LLVM version 19.1.5\r\nOptimized build.\r\n", + stderr="", + ), + ) + + with ( + mock.patch.dict(os.environ, original, clear=True), + mock.patch("core.toolchain.shutil.which", return_value=str(pwsh)) as which, + mock.patch("core.toolchain.subprocess.run", side_effect=responses) as run, + ): + host_before = dict(os.environ) + toolchain = discover_msvc_toolchain() + host_after = dict(os.environ) + + self.assertEqual(toolchain.installation, installation.resolve()) + self.assertEqual(toolchain.msbuild, msbuild.resolve()) + self.assertEqual(toolchain.vsdevcmd, vsdevcmd.resolve()) + self.assertEqual(toolchain.llvm_dir, llvm_dir.resolve()) + self.assertEqual(toolchain.clang_tidy, clang_tidy.resolve()) + self.assertEqual(toolchain.vcpkg_root, vcpkg_root.resolve()) + self.assertEqual(toolchain.pwsh, pwsh.resolve()) + self.assertEqual( + dict(toolchain.environment), + { + "LIB": str(lib), + "VCToolsVersion": "14.44.35207", + "VisualStudioVersion": "17.0", + "VSCMD_VER": "17.14.15", + "WindowsSDKVersion": "10.0.26100.0\\", + }, + ) + self.assertEqual( + dict(toolchain.identity), + { + "clang_tidy": str(clang_tidy.resolve()), + "clang_tidy_version": "19.1.5", + "installation": str(installation.resolve()), + "msbuild": str(msbuild.resolve()), + "msbuild_version": "17.14.51.32402", + "pwsh": str(pwsh.resolve()), + "vc_tools_version": "14.44.35207", + "vcpkg_root": str(vcpkg_root.resolve()), + "vsdevcmd": str(vsdevcmd.resolve()), + "vsdevcmd_version": "17.14.15", + "windows_sdk_version": "10.0.26100.0\\", + }, + ) + self.assertEqual(host_after, host_before) + self.assertEqual(which.call_args_list, [mock.call("pwsh")]) + self.assertEqual( + run.call_args_list[0].args[0], + [ + str(vswhere.resolve()), + "-latest", + "-products", + "*", + "-requires", + "Microsoft.Component.MSBuild", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "Microsoft.VisualStudio.Component.VC.Llvm.Clang", + "-property", + "installationPath", + ], + ) + payload = ( + f'call "{vsdevcmd.resolve()}" -no_logo -arch=amd64 ' + "-host_arch=amd64 >nul && set" + ) + self.assertEqual( + run.call_args_list[1].args[0], + f'"{cmd.resolve()}" /d /s /c "{payload}"', + ) + self.assertEqual( + run.call_args_list[0].kwargs, + {"check": True, "capture_output": True, "text": True}, + ) + self.assertEqual( + run.call_args_list[1].kwargs, + { + "check": True, + "capture_output": True, + "executable": str(cmd.resolve()), + "text": True, + }, + ) + self.assertEqual(run.call_args_list[2].args[0], [str(msbuild.resolve()), "-version", "-nologo"]) + self.assertEqual(run.call_args_list[3].args[0], [str(clang_tidy.resolve()), "--version"]) + for call in (run.call_args_list[2], run.call_args_list[3]): + self.assertEqual( + call.kwargs, + {"check": True, "capture_output": True, "text": True}, + ) + + def test_falls_back_to_bin_msbuild_and_vcpkg_on_path(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + program_files = root / "Program Files (x86)" + system_root = root / "Windows" + installation = root / "Visual Studio" + executable( + program_files + / "Microsoft Visual Studio" + / "Installer" + / "vswhere.exe" + ) + executable(system_root / "System32" / "cmd.exe") + msbuild = executable( + installation / "MSBuild" / "Current" / "Bin" / "MSBuild.exe" + ) + executable(installation / "Common7" / "Tools" / "VsDevCmd.bat") + executable(installation / "VC" / "Tools" / "Llvm" / "x64" / "bin" / "clang-tidy.exe") + pwsh = executable(root / "PowerShell" / "pwsh.exe") + vcpkg = executable(root / "vcpkg" / "2026.07.29" / "vcpkg.exe") + responses = ( + subprocess.CompletedProcess([], 0, stdout=str(installation), stderr=""), + subprocess.CompletedProcess([], 0, stdout="PATH=tools\r\n", stderr=""), + subprocess.CompletedProcess([], 0, stdout="17.14.51.32402\r\n", stderr=""), + subprocess.CompletedProcess([], 0, stdout="LLVM version 19.1.5\r\n", stderr=""), + ) + + def which(name: str) -> str: + return str({"pwsh": pwsh, "vcpkg": vcpkg}[name]) + + with ( + mock.patch.dict( + os.environ, + { + "ProgramFiles(x86)": str(program_files), + "SystemRoot": str(system_root), + }, + clear=True, + ), + mock.patch("core.toolchain.shutil.which", side_effect=which) as find, + mock.patch("core.toolchain.subprocess.run", side_effect=responses), + ): + toolchain = discover_msvc_toolchain() + + self.assertEqual(toolchain.msbuild, msbuild.resolve()) + self.assertEqual(toolchain.vcpkg_root, vcpkg.resolve().parent) + self.assertEqual(find.call_args_list, [mock.call("vcpkg"), mock.call("pwsh")]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_vcpkg_template.py b/build/tests/test_vcpkg_template.py new file mode 100644 index 0000000..ab11f16 --- /dev/null +++ b/build/tests/test_vcpkg_template.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +from jinja2 import UndefinedError + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) + +from core.render import TemplateRenderer # noqa: E402 + + +class VcpkgTemplateTests(unittest.TestCase): + def setUp(self) -> None: + self.renderer = TemplateRenderer(BUILD_ROOT / "templates") + self.variables = { + "name": "restore-vcpkg-x64", + "pool": "slot", + "inputs": [], + "results": [], + "pwsh": "pwsh.exe", + "vcpkg": r"C:\tools\vcpkg.exe", + "repository": r"C:\repo with O'Brien", + "triplet": "observer-x64-windows-static", + } + + def test_manifest_restore_targets_node_output_and_requires_include_directory(self) -> None: + recipe = json.loads(self.renderer.render("vcpkg.ps1", self.variables)) + script = recipe["script"]["data"] + + self.assertIn("Invoke-Checked 'C:\\tools\\vcpkg.exe' @(", script) + self.assertIn("\n 'install'\n", script) + self.assertIn('\n "--x-install-root=$outDir"\n', script) + self.assertIn("\n '--triplet'\n 'observer-x64-windows-static'\n", script) + self.assertIn("'--x-manifest-root=C:\\repo with O''Brien'", script) + self.assertIn("'--overlay-triplets=C:\\repo with O''Brien\\build\\vcpkg\\triplets'", script) + self.assertIn( + "Test-Path -LiteralPath (Join-Path $outDir " + "'observer-x64-windows-static\\include') -PathType Container", + script, + ) + self.assertIn("throw 'vcpkg restore did not produce the include directory'", script) + + def test_build_scratch_is_confined_while_vcpkg_manages_shared_downloads(self) -> None: + recipe = json.loads(self.renderer.render("vcpkg.ps1", self.variables)) + script = recipe["script"]["data"] + + self.assertIn('\n "--x-buildtrees-root=$buildDir\\b"\n', script) + self.assertIn('\n "--x-packages-root=$buildDir\\p"\n', script) + self.assertNotIn("--downloads-root", script) + self.assertIn('\n "--x-install-root=$outDir"\n', script) + + def test_triplet_is_required(self) -> None: + del self.variables["triplet"] + + with self.assertRaisesRegex(UndefinedError, "triplet.*undefined"): + self.renderer.render("vcpkg.ps1", self.variables) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/tests/test_windows_job.py b/build/tests/test_windows_job.py new file mode 100644 index 0000000..1c1306a --- /dev/null +++ b/build/tests/test_windows_job.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +import win32job + +from core.windows_job import WindowsJob + + +class FakeHandle: + def __init__( + self, + events: list[tuple[object, ...]], + *, + close_error: BaseException | None = None, + ) -> None: + self._events = events + self._close_error = close_error + + def Close(self) -> None: + self._events.append(("close",)) + if self._close_error is not None: + raise self._close_error + + +class FakeWin32Job: + JobObjectExtendedLimitInformation = ( + win32job.JobObjectExtendedLimitInformation + ) + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = ( + win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ) + + def __init__( + self, + *, + configure_error: BaseException | None = None, + close_error: BaseException | None = None, + ) -> None: + self.events: list[tuple[object, ...]] = [] + self.handle = FakeHandle(self.events, close_error=close_error) + self.configure_error = configure_error + self.information = { + "BasicLimitInformation": {"LimitFlags": 0x40}, + "IoInfo": {}, + } + + def CreateJobObject(self, attributes: object, name: str) -> FakeHandle: + self.events.append(("create", attributes, name)) + return self.handle + + def QueryInformationJobObject( + self, handle: FakeHandle, information_class: int + ) -> dict[str, object]: + self.events.append(("query", handle, information_class)) + return self.information + + def SetInformationJobObject( + self, + handle: FakeHandle, + information_class: int, + information: dict[str, object], + ) -> None: + self.events.append(("set", handle, information_class, information)) + if self.configure_error is not None: + raise self.configure_error + + def AssignProcessToJobObject( + self, handle: FakeHandle, process_handle: int + ) -> None: + self.events.append(("assign", handle, process_handle)) + + def TerminateJobObject(self, handle: FakeHandle, exit_code: int) -> None: + self.events.append(("terminate", handle, exit_code)) + + +class WindowsJobTests(unittest.TestCase): + def job(self, api: FakeWin32Job) -> WindowsJob: + with patch("core.windows_job.win32job", api): + return WindowsJob() + + def test_existing_limits_are_preserved_and_kill_on_close_precedes_assign( + self, + ) -> None: + api = FakeWin32Job() + + with patch("core.windows_job.win32job", api): + job = WindowsJob() + job.assign_process(202) + job.close() + + self.assertEqual( + [event[0] for event in api.events], + ["create", "query", "set", "assign", "close"], + ) + self.assertEqual(api.events[0], ("create", None, "")) + self.assertEqual( + api.information["BasicLimitInformation"][ # type: ignore[index] + "LimitFlags" + ], + 0x40 | win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + ) + + def test_close_is_idempotent(self) -> None: + api = FakeWin32Job() + job = self.job(api) + + with patch("core.windows_job.win32job", api): + job.close() + job.close() + + self.assertEqual(api.events.count(("close",)), 1) + + def test_configuration_failure_closes_handle_and_propagates(self) -> None: + api = FakeWin32Job(configure_error=OSError("configuration failed")) + + with patch("core.windows_job.win32job", api): + with self.assertRaisesRegex(OSError, "configuration failed"): + WindowsJob() + + self.assertEqual( + [event[0] for event in api.events], + ["create", "query", "set", "close"], + ) + + def test_configuration_failure_preserves_close_failure_as_note(self) -> None: + api = FakeWin32Job( + configure_error=OSError("configuration failed"), + close_error=OSError("close failed"), + ) + + with patch("core.windows_job.win32job", api): + with self.assertRaisesRegex(OSError, "configuration failed") as raised: + WindowsJob() + + self.assertTrue( + any("close failed" in note for note in raised.exception.__notes__) + ) + + def test_close_failure_keeps_handle_available_for_tree_termination(self) -> None: + api = FakeWin32Job(close_error=OSError("close failed")) + job = self.job(api) + + with self.assertRaisesRegex(OSError, "close failed"): + job.close() + with patch("core.windows_job.win32job", api): + job.terminate() + + self.assertEqual(api.events.count(("close",)), 1) + self.assertEqual(api.events[-1], ("terminate", api.handle, 1)) + + +@unittest.skipUnless(os.name == "nt", "requires Windows Job Objects") +class WindowsJobIntegrationTests(unittest.TestCase): + def test_real_job_can_be_configured_and_closed(self) -> None: + job = WindowsJob() + job.close() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_windows_process.py b/build/tests/test_windows_process.py new file mode 100644 index 0000000..6d9adb2 --- /dev/null +++ b/build/tests/test_windows_process.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import asyncio +import io +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +import psutil + + +BUILD_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BUILD_ROOT)) +EXE = str(Path(sys.executable).resolve()) + +from core.graph import Command # noqa: E402 +from core.windows_process import WindowsProcessRunner # noqa: E402 + + +class FakeProcess: + def __init__( + self, + events: list[tuple[object, ...]], + *, + resume_error: BaseException | None = None, + communicate_error: BaseException | None = None, + kill_error: BaseException | None = None, + exit_code: int = 0, + ) -> None: + self._handle = 301 + self.returncode: int | None = None + self._events = events + self._resume_error = resume_error + self._communicate_error = communicate_error + self._kill_error = kill_error + self._exit_code = exit_code + self.communicate_started = threading.Event() + self.communicate_release = threading.Event() + self.communicate_release.set() + + def resume(self) -> None: + self._events.append(("resume",)) + if self._resume_error is not None: + raise self._resume_error + + def communicate(self, *, input: bytes) -> tuple[None, None]: + self._events.append(("communicate-start", input)) + self.communicate_started.set() + self.communicate_release.wait(timeout=5) + self._events.append(("communicate-done",)) + if self._communicate_error is not None: + raise self._communicate_error + self.returncode = self._exit_code + return None, None + + def kill(self) -> None: + self._events.append(("kill",)) + self.communicate_release.set() + if self._kill_error is not None: + raise self._kill_error + + def wait(self) -> int: + self._events.append(("wait",)) + self.communicate_release.wait(timeout=5) + self.returncode = self._exit_code + return self._exit_code + + +class FakePopenFactory: + def __init__( + self, + process: FakeProcess, + *, + create_error: BaseException | None = None, + ) -> None: + self._process = process + self._create_error = create_error + self.calls: list[tuple[tuple[str, ...], dict[str, object]]] = [] + + def __call__(self, argv: list[str], **kwargs: object) -> FakeProcess: + self._process._events.append(("popen",)) + self.calls.append((tuple(argv), kwargs)) + if self._create_error is not None: + raise self._create_error + return self._process + + +class FakeJob: + def __init__( + self, + process: FakeProcess, + *, + assign_error: BaseException | None = None, + close_error: BaseException | None = None, + terminate_error: BaseException | None = None, + ) -> None: + self._process = process + self._assign_error = assign_error + self._close_error = close_error + self._terminate_error = terminate_error + self.closed = False + process._events.append(("create-job",)) + + def assign_process(self, process_handle: int) -> None: + self._process._events.append(("assign", process_handle)) + if self._assign_error is not None: + raise self._assign_error + + def close(self) -> None: + if self.closed: + return + self.closed = True + self._process._events.append(("close-job",)) + if self._close_error is not None: + raise self._close_error + self._process.communicate_release.set() + + def terminate(self) -> None: + self._process._events.append(("terminate-job",)) + self._process.communicate_release.set() + if self._terminate_error is not None: + raise self._terminate_error + + +class WindowsProcessRunnerTests(unittest.IsolatedAsyncioTestCase): + def patch_runtime( + self, + process: FakeProcess, + *, + create_error: BaseException | None = None, + assign_error: BaseException | None = None, + close_error: BaseException | None = None, + terminate_error: BaseException | None = None, + ) -> FakePopenFactory: + popen = FakePopenFactory(process, create_error=create_error) + self.enterContext(patch("core.windows_process.psutil.Popen", popen)) + self.enterContext( + patch( + "core.windows_process.WindowsJob", + new=lambda: FakeJob( + process, + assign_error=assign_error, + close_error=close_error, + terminate_error=terminate_error, + ), + ) + ) + return popen + + async def test_literal_argv_is_assigned_before_public_resume(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events, exit_code=23) + popen = self.patch_runtime(process) + log = io.BytesIO() + command = Command( + (EXE, "literal ; & | argument", 'quote"inside'), + env=(("ZED", "last"), ("Alpha", "first"), ("PATH", "tools")), + cwd=r"C:\repo\work dir", + stdin=b"Write-Output 'exact'\r\nexit 23\r\n", + ) + + with patch.dict( + os.environ, {"HOST": "base", "Path": "host", "zed": "host"}, clear=True + ): + exit_code = await WindowsProcessRunner().run(command, log=log) + + self.assertEqual(exit_code, 23) + argv, options = popen.calls[0] + self.assertEqual(argv, command.argv) + self.assertIs(options["stdout"], log) + self.assertIs(options["stderr"], subprocess.STDOUT) + self.assertIs(options["stdin"], subprocess.PIPE) + self.assertIs(options["shell"], False) + self.assertIs(options["close_fds"], True) + self.assertEqual(options["executable"], command.argv[0]) + self.assertEqual(options["cwd"], command.cwd) + self.assertEqual( + options["env"], + { + "Alpha": "first", + "HOST": "base", + "PATH": "tools" + os.pathsep + "host", + "ZED": "last", + }, + ) + self.assertEqual(options["creationflags"], 0x00000404) + self.assertLess(events.index(("assign", 301)), events.index(("resume",))) + self.assertLess( + events.index(("resume",)), + events.index(("communicate-start", command.stdin)), + ) + self.assertEqual(events.count(("close-job",)), 1) + self.assertNotIn(("kill",), events) + + async def test_executable_must_be_an_absolute_existing_regular_file(self) -> None: + invalid = ("tool.exe", str(BUILD_ROOT / "missing.exe"), str(BUILD_ROOT)) + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + popen = self.patch_runtime(process) + for executable in invalid: + with self.subTest(executable=executable): + with self.assertRaisesRegex(ValueError, "executable"): + await WindowsProcessRunner().run( + Command((executable,)), log=io.BytesIO() + ) + self.assertEqual(events, []) + self.assertEqual(popen.calls, []) + + async def test_popen_failure_closes_job(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + self.patch_runtime(process, create_error=OSError("create failed")) + + with self.assertRaisesRegex(OSError, "create failed"): + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertEqual(events, [("create-job",), ("popen",), ("close-job",)]) + + async def test_assignment_failure_kills_suspended_process_and_reaps(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + self.patch_runtime(process, assign_error=OSError("assign failed")) + + with self.assertRaisesRegex(OSError, "assign failed"): + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertNotIn(("resume",), events) + self.assertLess(events.index(("close-job",)), events.index(("kill",))) + self.assertIn(("wait",), events) + + async def test_resume_failure_closes_assigned_job_and_reaps(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events, resume_error=OSError("resume failed")) + self.patch_runtime(process) + + with self.assertRaisesRegex(OSError, "resume failed"): + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertLess(events.index(("assign", 301)), events.index(("resume",))) + self.assertLess(events.index(("close-job",)), events.index(("wait",))) + self.assertNotIn(("kill",), events) + + async def test_cancellation_closes_job_then_waits_for_communicate(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + process.communicate_release.clear() + self.patch_runtime(process) + task = asyncio.create_task( + WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + ) + await asyncio.to_thread(process.communicate_started.wait, 5) + + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + self.assertLess( + events.index(("close-job",)), events.index(("communicate-done",)) + ) + self.assertNotIn(("kill",), events) + + async def test_job_close_failure_terminates_assigned_job_and_is_noted(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events, communicate_error=OSError("write failed")) + self.patch_runtime(process, close_error=OSError("job close failed")) + + with self.assertRaisesRegex(OSError, "write failed") as raised: + await WindowsProcessRunner().run( + Command((EXE,), stdin=b"recipe"), log=io.BytesIO() + ) + + self.assertLess( + events.index(("close-job",)), events.index(("terminate-job",)) + ) + self.assertNotIn(("kill",), events) + self.assertTrue( + any("job close failed" in note for note in raised.exception.__notes__) + ) + + async def test_success_does_not_hide_job_close_failure(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess(events) + self.patch_runtime(process, close_error=OSError("job close failed")) + + with self.assertRaisesRegex(OSError, "job close failed"): + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertLess( + events.index(("close-job",)), events.index(("terminate-job",)) + ) + self.assertNotIn(("kill",), events) + + async def test_kill_failure_is_noted_on_primary_failure(self) -> None: + events: list[tuple[object, ...]] = [] + process = FakeProcess( + events, + communicate_error=OSError("write failed"), + kill_error=OSError("kill failed"), + ) + self.patch_runtime( + process, + close_error=OSError("job close failed"), + terminate_error=OSError("job terminate failed"), + ) + + with self.assertRaisesRegex(OSError, "write failed") as raised: + await WindowsProcessRunner().run(Command((EXE,)), log=io.BytesIO()) + + self.assertLess(events.index(("close-job",)), events.index(("terminate-job",))) + self.assertLess(events.index(("terminate-job",)), events.index(("kill",))) + notes = raised.exception.__notes__ + self.assertTrue(any("job close failed" in note for note in notes)) + self.assertTrue(any("job terminate failed" in note for note in notes)) + self.assertTrue(any("kill failed" in note for note in notes)) + + +@unittest.skipUnless(os.name == "nt", "requires Windows Job Objects") +class WindowsProcessRunnerIntegrationTests(unittest.IsolatedAsyncioTestCase): + @staticmethod + def command(*arguments: str, stdin: bytes = b"") -> Command: + return Command( + (EXE, *arguments), + env=tuple(os.environ.items()), + cwd=str(BUILD_ROOT), + stdin=stdin, + ) + + async def test_real_process_receives_exact_stdin_and_combines_log(self) -> None: + script = ( + "import sys; data=sys.stdin.buffer.read(); " + "sys.stdout.buffer.write(b'OUT:' + data); " + "sys.stderr.buffer.write(b'|ERR'); raise SystemExit(23)" + ) + payload = b"literal ; & | \x00 recipe\r\n" + with tempfile.TemporaryDirectory() as directory: + log_path = Path(directory) / "process.log" + with log_path.open("w+b") as log: + exit_code = await WindowsProcessRunner().run( + self.command("-c", script, stdin=payload), log=log + ) + + self.assertEqual(exit_code, 23) + self.assertEqual(log_path.read_bytes(), b"OUT:" + payload + b"|ERR") + + async def test_real_cancellation_kills_descendant_process(self) -> None: + script = ( + "import subprocess,sys,time; " + "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(300)']); " + "print(p.pid, flush=True); time.sleep(300)" + ) + child: psutil.Process | None = None + task: asyncio.Task[int] | None = None + with tempfile.TemporaryDirectory() as directory: + log_path = Path(directory) / "tree.log" + try: + with log_path.open("w+b") as log: + task = asyncio.create_task( + WindowsProcessRunner().run( + self.command("-c", script), log=log + ) + ) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and not task.done(): + content = log_path.read_text(encoding="ascii").strip() + if content: + child = psutil.Process(int(content)) + break + await asyncio.sleep(0.05) + if child is None: + if task.done(): + await task + self.fail("child process pid was not reported") + + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + gone, alive = psutil.wait_procs([child], timeout=10) + self.assertEqual(gone, [child]) + self.assertEqual(alive, []) + finally: + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + if child is not None and child.is_running(): + child.kill() + child.wait(timeout=5) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/build/tests/test_workflow_contract.py b/build/tests/test_workflow_contract.py new file mode 100644 index 0000000..ebe8ae5 --- /dev/null +++ b/build/tests/test_workflow_contract.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from pathlib import Path +import re +import unittest + + +REPOSITORY = Path(__file__).parents[2] + + +class WorkflowContractTests(unittest.TestCase): + def test_main_ci_is_only_an_automatic_thin_public_verify_client(self) -> None: + workflow = (REPOSITORY / ".github/workflows/main.yml").read_text(encoding="utf-8") + + self.assertIn("pull_request:", workflow) + self.assertIn("push:", workflow) + self.assertGreaterEqual(workflow.count("branches: [master]"), 2) + self.assertNotIn("workflow_dispatch", workflow) + self.assertNotIn("schedule:", workflow) + self.assertNotIn("pull_request_target", workflow) + self.assertIn("cancel-in-progress: ${{ github.event_name == 'pull_request' }}", workflow) + + self.assertIn("runs-on: windows-2022", workflow) + self.assertIn("fail-fast: false", workflow) + matrix_rows = re.findall(r"- job: ([^\n]+)\n\s+arch: ([^\n]+)", workflow) + self.assertEqual( + matrix_rows, + [("source", "none"), ("x86", "x86"), ("x64", "x64"), ("arm64-cross", "arm64")], + ) + self.assertEqual(workflow.count("./build.ps1 verify-source"), 1) + self.assertEqual(workflow.count("./build.ps1 verify-arch"), 1) + self.assertEqual(workflow.count("-PruneCas"), 1) + self.assertNotIn("./build.ps1 verify -Arch", workflow) + self.assertNotIn("continue-on-error", workflow) + + self.assertIn("upload-artifact", workflow) + self.assertIn("id: verify-source", workflow) + self.assertIn("id: verify-arch", workflow) + self.assertIn( + "if: always() && (steps.verify-source.outcome != 'skipped' || " + "steps.verify-arch.outcome != 'skipped')", + workflow, + ) + self.assertIn("/manifest.json", workflow) + self.assertIn("/reports", workflow) + self.assertIn("/logs", workflow) + self.assertNotIn("!${{ runner.temp }}", workflow) + self.assertIn("if-no-files-found: error", workflow) + self.assertIn( + "if: success() && github.event_name == 'push' && matrix.job != 'source'", + workflow, + ) + self.assertIn("/packages", workflow) + self.assertNotIn("if-no-files-found: ignore", workflow) + self.assertIn("permissions:\n contents: read", workflow) + self.assertEqual(workflow.count("path: out/cas"), 2) + self.assertIn("id: restore-build-cas", workflow) + self.assertIn("uses: actions/cache/restore@", workflow) + self.assertIn("uses: actions/cache/save@", workflow) + self.assertIn( + "key: cas-v1-${{ matrix.job }}-${{ github.sha }}-${{ github.run_id }}-" + "${{ github.run_attempt }}", + workflow, + ) + self.assertIn("cas-v1-${{ matrix.job }}-${{ github.sha }}-", workflow) + self.assertIn("cas-v1-${{ matrix.job }}-", workflow) + self.assertNotIn("cas-v1-${{ steps.runner-image.outputs.identity }}", workflow) + self.assertIn("if: always() && matrix.job != 'source'", workflow) + self.assertIn( + "key: ${{ steps.restore-build-cas.outputs.cache-primary-key }}", workflow + ) + self.assertNotIn("save-always", workflow) + self.assertNotIn("out/work", workflow) + self.assertNotIn("contents: write", workflow) + self.assertNotIn("download-artifact", workflow) + self.assertNotIn("upload-sarif", workflow) + self.assertNotIn("codeql-action", workflow) + + actions = dict(re.findall(r"uses:\s+([^@\s]+)@([^\s#]+)", workflow)) + self.assertEqual( + actions, + { + "actions/checkout": "d23441a48e516b6c34aea4fa41551a30e30af803", + "astral-sh/setup-uv": "08807647e7069bb48b6ef5acd8ec9567f424441b", + "actions/cache": "caa296126883cff596d87d8935842f9db880ef25", + "actions/cache/restore": "caa296126883cff596d87d8935842f9db880ef25", + "actions/cache/save": "caa296126883cff596d87d8935842f9db880ef25", + "actions/upload-artifact": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + }, + ) + for action, revision in actions.items(): + with self.subTest(action=action): + self.assertRegex(revision, r"^[0-9a-f]{40}$") + self.assertIn("persist-credentials: false", workflow) + self.assertIn('version: "0.12.1"', workflow) + self.assertIn("id: runner-image", workflow) + self.assertIn("${{ steps.runner-image.outputs.identity }}", workflow) + self.assertIn('"TEMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV', workflow) + self.assertIn('"TMP=$env:RUNNER_TEMP" | Add-Content -Path $env:GITHUB_ENV', workflow) + self.assertIn( + "$baseline = (Get-Content -Raw -LiteralPath 'vcpkg.json' | " + "ConvertFrom-Json).'builtin-baseline'", + workflow, + ) + self.assertIn( + "git -C $env:VCPKG_ROOT fetch --no-tags --depth=1 origin $baseline", + workflow, + ) + self.assertIn("checkout --detach --force $baseline", workflow) + self.assertIn("bootstrap-vcpkg.bat", workflow) + self.assertIn("C:\\Program Files\\Cppcheck", workflow) + self.assertIn("nuget install Microsoft.CodeAnalysis.BinSkim", workflow) + self.assertIn("tools\\net9.0\\win-x64\\BinSkim.exe", workflow) + self.assertNotIn("dotnet tool install --global Microsoft.CodeAnalysis.BinSkim", workflow) + self.assertIn("$env:GITHUB_PATH", workflow) + self.assertIn("if: matrix.job == 'x64'", workflow) + self.assertIn( + "https://download.microsoft.com/download/e119c04b-71aa-4067-ac3c-360c2e13d209/" + "windowssdk/Installers/X64%20Debuggers%20And%20Tools-x64_en-us.msi", + workflow, + ) + self.assertIn("354173D844D5C061050EE2638AA94FAFB4835AC3DE836E220F6A74A992849A3B", workflow) + self.assertIn( + '$process = Start-Process -FilePath "$env:SystemRoot\\System32\\msiexec.exe"', + workflow, + ) + self.assertIn( + '$arguments = @(\'/a\', $msi, \'/qn\', \'/norestart\', "TARGETDIR=$extract")', + workflow, + ) + self.assertNotIn("'/layout'", workflow) + self.assertNotIn("'/installpath'", workflow) + self.assertIn("'10.0.19041.'", workflow) + self.assertIn("$gflags = Join-Path $debuggers 'gflags.exe'", workflow) + self.assertIn('"OBSERVER_UMDH=$umdh" | Add-Content -Path $env:GITHUB_ENV', workflow) + + for duplicated_gate in ( + "./build.ps1 source-checks", + "./build.ps1 compiler-analysis", + "./build.ps1 test-coverage", + "./build.ps1 test-asan", + "./build.ps1 test-ubsan", + "./build.ps1 test-leaks", + "./build.ps1 fuzz", + "./build.ps1 audit-binaries", + "./build.ps1 package", + ): + self.assertNotIn(duplicated_gate, workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/build/uv.lock b/build/uv.lock new file mode 100644 index 0000000..8f37f88 --- /dev/null +++ b/build/uv.lock @@ -0,0 +1,146 @@ +version = 1 +revision = 3 +requires-python = "==3.14.6" + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "observer-build" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "coverage" }, + { name = "filelock" }, + { name = "jinja2" }, + { name = "psutil" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "coverage", specifier = "==7.15.2" }, + { name = "filelock", specifier = "==3.32.2" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "psutil", specifier = "==7.2.2" }, + { name = "pywin32", marker = "sys_platform == 'win32'", specifier = "==312" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, +] diff --git a/build/vcpkg/triplets/observer-arm64-windows-static.cmake b/build/vcpkg/triplets/observer-arm64-windows-static.cmake new file mode 100644 index 0000000..9ea8b57 --- /dev/null +++ b/build/vcpkg/triplets/observer-arm64-windows-static.cmake @@ -0,0 +1,5 @@ +set(VCPKG_TARGET_ARCHITECTURE arm64) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_C_FLAGS "/W4 /Qspectre") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre") diff --git a/build/vcpkg/triplets/observer-x64-windows-static-asan.cmake b/build/vcpkg/triplets/observer-x64-windows-static-asan.cmake new file mode 100644 index 0000000..c347497 --- /dev/null +++ b/build/vcpkg/triplets/observer-x64-windows-static-asan.cmake @@ -0,0 +1,6 @@ +set(VCPKG_TARGET_ARCHITECTURE x64) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_BUILD_TYPE release) +set(VCPKG_C_FLAGS "/W4 /Qspectre /fsanitize=address") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre /fsanitize=address") diff --git a/build/vcpkg/triplets/observer-x64-windows-static.cmake b/build/vcpkg/triplets/observer-x64-windows-static.cmake new file mode 100644 index 0000000..8e9ad7f --- /dev/null +++ b/build/vcpkg/triplets/observer-x64-windows-static.cmake @@ -0,0 +1,5 @@ +set(VCPKG_TARGET_ARCHITECTURE x64) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_C_FLAGS "/W4 /Qspectre") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre") diff --git a/build/vcpkg/triplets/observer-x86-windows-static-asan.cmake b/build/vcpkg/triplets/observer-x86-windows-static-asan.cmake new file mode 100644 index 0000000..5909984 --- /dev/null +++ b/build/vcpkg/triplets/observer-x86-windows-static-asan.cmake @@ -0,0 +1,6 @@ +set(VCPKG_TARGET_ARCHITECTURE x86) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_BUILD_TYPE release) +set(VCPKG_C_FLAGS "/W4 /Qspectre /fsanitize=address") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre /fsanitize=address") diff --git a/build/vcpkg/triplets/observer-x86-windows-static.cmake b/build/vcpkg/triplets/observer-x86-windows-static.cmake new file mode 100644 index 0000000..f4743bb --- /dev/null +++ b/build/vcpkg/triplets/observer-x86-windows-static.cmake @@ -0,0 +1,5 @@ +set(VCPKG_TARGET_ARCHITECTURE x86) +set(VCPKG_CRT_LINKAGE static) +set(VCPKG_LIBRARY_LINKAGE static) +set(VCPKG_C_FLAGS "/W4 /Qspectre") +set(VCPKG_CXX_FLAGS "/W4 /Qspectre") diff --git a/copy_dlls.cmd b/copy_dlls.cmd deleted file mode 100644 index e12a55d..0000000 --- a/copy_dlls.cmd +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -pushd %~dp0build\%1 -set MODULES_DIR=%DEBUGFARHOME%\Plugins\Observer\modules -copy /Y *.so %MODULES_DIR% || exit 1 -copy /Y *.pdb %MODULES_DIR% -popd \ No newline at end of file diff --git a/docs/build-system.md b/docs/build-system.md new file mode 100644 index 0000000..135bd37 --- /dev/null +++ b/docs/build-system.md @@ -0,0 +1,484 @@ +# Build system and engineering workflow + +## Status + +This document describes the implemented local build. The repository uses a Python/Jinja content-addressed DAG for +orchestration and keeps MSBuild as the native compile/link backend. + +The IX-aligned build model and automatic CI sections below record implemented contracts. A feature described as +future or deferred is not part of the current gate. + +## Non-negotiable release contract + +- Windows-only native C++23, compiled with the MSVC `cl.exe` toolchain. +- Release modules are built for x86, x64, and ARM64. +- The MSVC runtime and all third-party libraries are linked statically (`/MT` and static vcpkg triplets). +- A release archive contains no redistributable runtime and no non-system DLL dependencies. +- CMake is not part of this repository's build graph. vcpkg ports may use CMake internally. +- `/clr` is not used. Managed-library integrations require a separate future design that preserves the native, + self-contained release contract. + +## Layers + +```text +build.ps1 / build.cmd stable command-line entry point + | + v +build/main.py command contract and graph composition + | + v +build/graphs/* fine-grained content-addressed DAG + | + v +build/projects/*.vcxproj compile/link graph + | + v +cl.exe / link.exe / lib.exe / rc.exe +``` + +The five-line root PowerShell script only enters the exact-pinned `uv` environment and forwards arguments. Python +builds and executes the outer DAG; short inherited Jinja templates render tool recipes. MSBuild still owns native +project evaluation, compilation, linking, and C++ header dependencies. Replacing that mature backend would duplicate +substantial toolchain behavior without a demonstrated critical-path benefit. + +### DAG and content-addressed storage + +The outer engine adapts the small model used by [pg83/ix](https://github.com/pg83/ix): a node declares `in_dir`, +`out_dir`, dependencies, data, one resource pool, and a Jinja recipe. Templates use inheritance and `StrictUndefined`; +PowerShell-specific quoting is centralized instead of repeated in every leaf. + +Canonical MD5 covers the fully rendered recipe, descriptor/argv data, declared input bytes and paths, dependency UIDs, +toolchain/platform identity, and the executor schema. MD5 is a fast local content identity, not a cryptographic trust +boundary for remote artifacts. A CAS hit requires a canonical entry directory, its `out` directory, regular `log.txt`, +and a zero-byte regular `touch` publication marker; failed or cancelled work cannot publish that marker. + +`filelock` coordinates node publication and clean operations between cooperating local processes. A public command +holds one run lease across dependency discovery, all executor phases, manifest reads, telemetry, pruning, and result +export; standalone executor use acquires the same lease implicitly. Mutable paths are confined below the exact +repository output root and existing reparse points are rejected. `psutil` launches and observes processes; a Windows +Job Object terminates the complete descendant tree on failure or cancellation. These are explicit local-build +guarantees, not a claim of hostile-process isolation. + +## IX-aligned build model + +The goal is not to import IX's package manager. It is to keep the small, proven part that this repository needs: short +inherited recipes, a dependency DAG, demand execution, content identities, and immutable successful outputs. The +native compiler backend remains MSBuild; replacing project evaluation, C++ dependency handling, compilation, and +linking is explicitly outside this stage. + +### Recipe model + +- One rendered recipe describes one cacheable node: readable name, direct dependencies, `in_dir`, `out_dir`, data, + one logical pool, and either an argv command or a shell body. +- Jinja inheritance owns command construction and repeated tool policy. The intended hierarchy is a small JSON base, + an argv or PowerShell base, an MSBuild base where relevant, and a short leaf containing only operation-specific data. + `StrictUndefined` remains mandatory. +- Graph-building Python enumerates targets and real dependency edges and supplies semantic data. It must stop rebuilding + the same argv, environment, path, and script boilerplate in every graph family. +- The rendered artifact remains a JSON/argv descriptor. PowerShell is one recipe backend, not the graph language. A + future local WSL2 implementation can add a POSIX-shell base without changing the node, DAG, or CAS model. +- Typed `results` become part of the recipe contract. Each result has a stable logical id, kind, media type, and a path + relative to the node output; graph code and CI must not infer results by scanning directories. + +### Identity and storage + +- Keep canonical MD5 and the zero-byte `touch` publication marker. MD5 identifies local content; any imported or + downloaded artifact must be authenticated separately. +- The UID covers the rendered recipe, declared input paths and bytes, dependency UIDs, semantic configuration, and a + normalized toolchain fingerprint. +- Dependency discovery signs every project-declared header plus header-like files beside the translation unit. The + compiler manifest is then checked fail-closed: any other first-party header dependency must be added to the + project's `` inventory before downstream graph construction can continue. +- Absolute checkout/CAS/export paths, `GITHUB_*`, commit and PR ids, log destinations, `Jobs`, pool capacities, and + scheduler order do not affect the UID. The explicit run nonce is the exception: it intentionally changes fuzz, + leak, and opt-in corpus test identities; the default CLI nonce is based on time and process id. +- Change the physical successful-entry key to `out/cas/`. The readable node name remains in graph diagnostics, + logs, and exported manifests instead of being duplicated in the CAS directory name. +- Keep mutable scratch, locks, leases, and incomplete-entry quarantine below `out/work`. A failed node may first leave + a markerless directory and log in `out/cas`; a later demand moves it to per-run quarantine before rebuilding. There + is no permanent IX-style trash directory: quarantine is recoverable during the run and stale work is removed by the + existing safe cleanup contract. + +### Execution + +- Every runnable node consumes one shared `Jobs` slot. A recipe declares at most one additional logical pool, but a + narrower pool is introduced only for a measured resource limit or a demonstrated tool serialization requirement. + UMDH and BinSkim have no speculative `2` and `1` caps; by default they use the shared budget like other nodes. +- Preserve all real fine-grained edges and shards: individual translation units/analyzers, test shards, fuzz targets, + leak scenarios and diffs, binary audits, and packages may overlap whenever their inputs are ready. +- The executor uses keep-going semantics. A failed node blocks only its descendants; independent ready work continues, + all failures are collected, successfully produced diagnostics remain publishable, and the command finally exits + nonzero. +- Continue using `filelock`, `psutil`, and the Windows Job Object rather than maintaining substitutes. Prefer a small, + well-maintained open-source dependency whenever it removes repository code without weakening the contract. + +### Public result boundary + +`-ExportDir` on verification and packaging commands copies only declared typed results to stable paths such +as `reports/sarif/x64/...`, `reports/coverage/x64/...`, and `packages/x64/...`; it never exposes the internal CAS +layout. The root `manifest.json` describes the self-contained evidence bundle. Successful package exports have a +separate `packages/manifest.json`, whose paths are relative to that bundle, so CI can publish evidence after failure +without publishing release ZIPs from pull requests. Manifests are written after their files and record command status, +result ids, relative paths, producer UIDs, sizes, and SHA-256 digests. Diagnostics produced before a later gate failure +are exported; release packages are exported only after their complete package gates pass. The export destination is +not part of recipe identity. The root manifest also records graph nodes and their UIDs as `hit`, `executed`, `failed`, +or `incomplete`. Durations are measured for executed and failed nodes; hits and incomplete nodes report zero. A hit is +any valid pre-existing CAS entry, whether restored by CI or already local. `incomplete` means the graph node neither +hit nor completed in this invocation, commonly because it was blocked; it is not an inventory of markerless CAS +directories. Telemetry is captured before pruning and does not report removed entries. + +## Supported commands + +```powershell +.\build.ps1 doctor +.\build.ps1 restore -Arch x64 +.\build.ps1 build -Arch x86,x64,arm64 -Config Release +.\build.ps1 test -Arch x64 -Config Debug +.\build.ps1 source-checks -Arch x86,x64,arm64 +.\build.ps1 compiler-analysis -Arch x64 +.\build.ps1 test-coverage -Arch x64 +.\build.ps1 test-asan -Arch x64 +.\build.ps1 test-ubsan -Arch x64 +.\build.ps1 test-leaks -Arch x64 +.\build.ps1 fuzz -Arch x64 -FuzzTarget all -FuzzSeconds 60 +.\build.ps1 audit-binaries -Arch x86,x64,arm64 +.\build.ps1 package -Arch x86,x64,arm64 +.\build.ps1 verify-source -ExportDir +.\build.ps1 verify-arch -Arch x64 -ExportDir +.\build.ps1 verify -Arch x64 +.\build.ps1 clean -CleanMode stale-work +``` + +`build.cmd` is a convenience shim for `cmd.exe`; both entry points execute the same Python driver through the root +PowerShell launcher. +Use `-FuzzTarget pickle|renpy|rpgmaker|zanzarah` for a focused local regression run; the default `all` runs every +format target. + +Without `-ExportDir`, commands may print their internal target paths for local diagnostics. Stable consumers use the +typed export boundary; mutable intermediates and locks remain below `out/work`. + +`verify` and `verify-arch` accept `-PruneCas`. The option is intended for bounded cache generations such as CI. On a +successful command it retains every complete node UID in the full graph, including unvisited dependencies of a cached +target, and removes unlocked non-live canonical directories before publishing success evidence. Without `-ExportDir` +the same success-path sweep runs without a manifest; a failed command without export keeps its internal failure data. +Normal local commands keep prior successful nodes for fast switching between targets and configurations. + +`verify` is the complete host-capable aggregate. It builds Debug and Release for every requested architecture, runs +deterministic tests only where the current host can execute them, and then runs source/compiler analysis, coverage, +the supported sanitizer/leak/fuzz gates, binary audit, package-content validation, and package runtime smoke. A +non-native runtime check is reported explicitly as deferred rather than falsely reported as executed. All gates are +designed to be runnable locally on the supported Windows host. +The verify fuzz work covers all four format targets; `-FuzzSeconds` controls each bounded run. + +Verification has two executor phases: a merged dependency-discovery graph, then one merged post-discovery graph. +Within the second phase, ready nodes from builds, tests, analyzers, coverage, sanitizers, fuzzing, leak checks, audit, +and packaging may overlap whenever their real dependencies allow it. `-Jobs` sets the shared global capacity. The +implementation has no unmeasured UMDH or BinSkim limits encoded as scheduler policy. + +## Configurations + +| Configuration | Purpose | CRT | Distributed | +|---|---|---|---| +| Debug | development and deterministic tests | `/MTd` | no | +| Release | optimized modules and packages | `/MT` | yes | +| Coverage | clang-cl instrumentation and llvm-cov reporting | `/MTd` | no | +| ASan | AddressSanitizer tests against release-only instrumented dependencies | `/MT` | no | +| UBSan | clang-cl undefined-behavior tests | `/MT` | no | +| Fuzz | libFuzzer plus AddressSanitizer | `/MT` | no | +| Leak | UMDH against optimized x64 Release modules and probe | `/MT` | no | + +ASan and fuzzing are supported only on x86 and x64. Their runtime DLLs are test-only dependencies and must never be +copied into release packages. + +## Dependency management + +Library dependencies are declared by `vcpkg.json` in manifest mode. Repository-owned overlay triplets explicitly set +both `VCPKG_CRT_LINKAGE` and `VCPKG_LIBRARY_LINKAGE` to `static` for all three architectures. The vcpkg baseline is +pinned in source control and packages are restored into architecture/flavor-specific CAS nodes; global vcpkg +integration is not required. Separate install roots keep manifest-mode vcpkg from pruning another architecture while +switching targets. + +Normal public commands include the exact restore nodes they require. Independent architecture/flavor restores may run +concurrently; ARM64 ASan is omitted because that configuration is unsupported. There is no restore-skipping switch: +restore is an ordinary content-addressed graph node and a valid hit is already a no-op. + +Developer tools are not library dependencies and are discovered by `doctor`: + +- `uv` and the repository environment initialized once with `uv sync --project build --frozen`; +- Visual Studio Build Tools 2022 with MSVC x86/x64 and ARM64 tools plus a Windows SDK; +- PowerShell 7.4 or newer; +- vcpkg; +- LLVM tools (`clang-format` and `clang-tidy`); +- Cppcheck; +- PSScriptAnalyzer for PowerShell sources. + +Normal `build.ps1` commands use `uv --frozen --no-sync`: they neither resolve nor download Python packages while a +build is running. `build/uv.lock` exact-pins Python 3.14.6 and the small runtime dependency set. + +## Compiler and static-analysis policy + +Normal compilation uses `/W4 /WX /permissive-`, the conforming preprocessor, correct `__cplusplus`, SDL checks, and +external-header warning suppression. Release additionally enables optimization and link-time code generation. + +The blocking analysis stack is: + +1. clang-format in check-only mode; +2. MSVC warnings as errors; +3. MSVC native code analysis (`/analyze` through MSBuild); +4. clang-tidy; +5. Cppcheck for the Release configuration of x86, x64, and ARM64; +6. PSScriptAnalyzer for PowerShell files. + +PVS-Studio is explicitly out of scope. Include What You Use is deferred: it is useful for direct/minimal include +hygiene, but its Windows mappings, LLVM version coupling, and false-positive cost do not justify making it a gate yet. +Header self-containment checks and clang-tidy's include diagnostics come first. + +Cppcheck suppressions must be narrow and documented. Repository-wide suppression of a diagnostic is not acceptable. + +### Analysis evidence + +Analysis work and the semantic gate are deliberately separate. Each analyzer may finish and preserve its report before +the deterministic merge/gate enforces findings. Cppcheck emits one SARIF file per architecture. +MSVC `/analyze` keeps one raw SARIF file per first-party project and assigns every run a stable +`msvc-analyze///` identity before the architecture directory is uploaded. The clang-tidy logs produced +by that same compile-only graph are converted into a deduplicated first-party SARIF file with the stable identity +`clang-tidy//`. + +BinSkim emits one release-binary SARIF file per architecture. Reports remain separate by analyzer and architecture and +are stored as local CAS evidence. A CI job may repeat these commands, but it must not be the only way to execute or +inspect any mandatory gate. + +The main GitHub workflow remains a thin client: it provisions an otherwise empty hosted runner, invokes public local +commands, and transports their declared results. It contains no private gate graph, report parser, CAS-path protocol, +or release logic. + +## Automatic CI + +CI contains no CI-only quality gate. Everything mandatory in GitHub must remain runnable from an ordinary local +console through the same public entry points: + +```powershell +.\build.ps1 verify-source -ExportDir +.\build.ps1 verify-arch -Arch x86 -ExportDir +.\build.ps1 verify-arch -Arch x64 -ExportDir +.\build.ps1 verify-arch -Arch arm64 -ExportDir +.\build.ps1 verify -Arch all -ExportDir +``` + +`verify-source` runs architecture-independent formatting, PowerShell analysis, Python tests/100% coverage, and +repository contracts exactly once. `verify-arch` runs the complete applicable graph for one architecture. The existing +`verify -Arch all` remains the local aggregate and composes source plus every architecture into one executor for +maximum local overlap; the split entry points do not define different gates. + +Only two automatic triggers are allowed: + +```yaml +on: + pull_request: + branches: [master] + push: + branches: [master] +``` + +There is no `workflow_dispatch`, scheduled workflow, or ARM64-native runner. The four required jobs are: + +| Job | Public command | Contract | +|---|---|---| +| `source` | `verify-source` | architecture-independent gates | +| `x86` | `verify-arch -Arch x86` | Debug/Release, runnable tests, analysis, ASan, audit, package validation | +| `x64` | `verify-arch -Arch x64` | x86-class gates plus coverage, UBSan, fuzzing, UMDH leaks, and package smoke | +| `arm64-cross` | `verify-arch -Arch arm64` | MSVC cross-build, analysis, binary audit, and package validation | + +ARM64 runtime tests are explicitly deferred; a successful cross job must not report them as executed. All four jobs +are required for pull requests and pushes to `master`. GitHub matrix/job fail-fast is disabled, and the graph's own +keep-going behavior preserves independent evidence inside each job. Superseded pull-request runs are cancelled; +`master` runs are never cancelled by a newer push. + +Pull requests use the same gates and thresholds with shorter explicit bounded-work parameters, for example a small +`-FuzzSeconds` value and minimal leak warm-up/iterations. Pushes to `master` use the full local defaults. There is no +hidden `pr`/`full` gate composition and no manual profile; changing a bounded duration never removes a target or +weakens the 100% coverage and zero-finding gates. + +The workflow caches dependency transport data and the `out/cas` content-addressed store. CAS caches are isolated by +architecture job, restored only within GitHub's branch/ref cache scope, and never shared with the source job. Their +outer restore prefixes deliberately span hosted-runner image revisions: node identities cover recipes, declared +inputs, dependency identities, configuration, runtime, and toolchain content, so incompatible nodes miss without +requiring a coarse image key. Each run and attempt has a unique generation key. Separate restore and save steps retain +the store after a later gate fails. Only canonical complete entries can hit: a demanded incomplete entry is +quarantined and rebuilt. Fuzz, leak, and opt-in corpus nodes include a run nonce. An absent or evicted cache therefore +changes only latency. The workflow never caches `out/work`, an installed vcpkg tree, or the separate +`ExportDir/packages` bundle; package-node outputs can still live inside the CAS cache. + +Architecture jobs pass `-PruneCas`. On success, telemetry is captured, mark-and-sweep removes unlocked canonical +directories not named by the command graph's live UID set, and then the success manifest is published. On execution +failure inside the complete final graph, available failure evidence is published before pruning so failed logs survive. +If manifest loading or final graph composition fails after discovery, evidence for the partial graph is exported but +pruning is skipped because that graph is not a complete liveness boundary. The workflow then saves a new immutable +GitHub cache generation. Locked and noncanonical entries are preserved; no access time or wall-clock age participates +in liveness. An early graph/export/prune failure can still reach the `always()` save step with an unpruned store. GitHub +eventually evicts older whole generations according to its cache retention policy; unlocked canonical stale entries do +not propagate after a successful prune. + +Each job attempts to upload the manifest, reports, and logs from its `-ExportDir` with `if: always()` so evidence from +completed independent branches survives a later failure; a failure after the verification step starts but before +manifest creation is reported as missing evidence. Pull-request evidence is retained for seven days; `master` +evidence for thirty days. The separate packages +subtree is uploaded only from successful `master` jobs. Actions are pinned to full commit SHAs, permissions default to +`contents: read`, untrusted pull requests receive no secrets, and `pull_request_target` is forbidden. + +The workflow first runs `doctor`, then exactly one public verification command. It uploads the self-contained evidence +bundle when verification produced one and uploads the separate package bundle only from successful `master` +architecture jobs. Branch protection requires `source`, `x86`, `x64`, and `arm64-cross`. + +The hosted x64 job selects UMDH from the serviced Windows 10 SDK 2004 line explicitly. This avoids the documented +allocation-stack capture defect in the UMDH shipped with Windows 11 SDKs without changing the locally runnable leak +gate or the SDK used to compile production binaries. + +### Future analysis backlog + +The following tools are deliberately recorded for later work so that they are not lost while the build and test +architecture is being stabilized: + +- **Coverity Scan:** evaluate it only if it can be integrated into the locally runnable verification contract. It is + not part of the approved CI workflow while submissions require a separate external/manual path. +- **Infer:** evaluate it locally under the future WSL2 parser-core workflow. Do not add a separate CI-only build path + for the Windows DLL adapters merely to accommodate Infer. +- **Include What You Use:** introduce it after parser/header separation has stabilized. Pin an IWYU release compatible + with the selected LLVM version, generate its compile commands from the canonical build graph, and review suggestions + rather than applying fixes automatically. It checks direct/minimal include ownership, not runtime correctness. +- **CBMC:** use it for small, security-critical units with explicit bounds, especially the bounded byte reader and + offset/size/allocation arithmetic. Write focused harnesses that prove properties such as no overflow and no + out-of-range access; whole-project model checking is not a goal. + +PC-lint Plus has been considered and explicitly rejected for this project. Do not add it to the required toolchain or +local verification matrix. + +## Tests + +Catch2 remains the test framework. Tests are split conceptually into: + +- deterministic unit tests for parsers, decryption, size/offset arithmetic, and error handling; +- Observer ABI contract tests, including invalid inputs and cancellation; +- small synthetic archive end-to-end tests committed to the repository; +- the large external golden corpus, selected with `OBSERVER_TEST_CORPUS` or `-Corpus`; +- package smoke tests that load the exact binary that will be distributed. + +An absent external corpus skips only corpus tests; it must not hide failures in deterministic tests. + +### Future parser-core boundary + +The current format parsing code is coupled to the Observer DLL adapter, filesystem operations, and parts of the test +harness. A future refactoring should separate a portable parser core from those concerns. This is an architectural +boundary, not a new shared runtime DLL: the core remains statically linked into each self-contained module, and the +published Observer exports and package layout remain unchanged. + +The intended responsibilities are: + +- the Observer adapter owns the public C ABI, Windows types, module lifetime, callbacks, and error translation; +- the parser core consumes a small bounded byte-source abstraction and produces validated archive metadata and entry + descriptions; +- format-specific implementations remain independent behind a factory or another narrow dispatch boundary; a virtual + base class is optional and should be introduced only if it simplifies the actual call sites and tests; +- extraction I/O is kept outside pure metadata parsing where practical, while decompression and format algorithms + remain testable without loading a plugin DLL. + +This boundary is expected to provide the following benefits: + +- deterministic unit tests for valid, malformed, truncated, and adversarial inputs without creating files or loading + DLLs; +- direct fuzz targets for every format parser instead of fuzzing through Observer or a large integration harness; +- substantially cheaper growth toward 100% branch coverage, with failures attributable to a single parser; +- one implementation of bounded reads, checked offset arithmetic, allocation limits, and entry-range validation; +- portable Clang builds of production parsing code for UBSan and LeakSanitizer on a supported non-Windows host; +- smaller test fixtures, reducing dependence on the multi-gigabyte external golden corpus; +- independent testing of the stable Observer ABI adapter against fake parser results and failure injection. + +The refactoring does not replace end-to-end tests. ABI contract tests, real DLL loading, package smoke tests, and a +smaller compatibility corpus remain necessary because they cover integration behavior that parser-core tests cannot. + +## Coverage + +The analysis-only Coverage configuration uses `clang-cl` source instrumentation and `llvm-cov` to enforce 100% lines +and branches in first-party production modules. Functions and regions remain visible in the report but are not separate +release gates. Test framework, Catch2, vcpkg, generated, and Windows SDK code is excluded. Its reports are evidence +about the deterministic test suite, not release artifacts. + +Canonical correctness tests and all distributed binaries continue to use MSVC `cl.exe`. The same deterministic tests +must pass under MSVC Debug before their clang-cl coverage result is accepted. + +## Sanitizers and fuzzing + +The primary ASan path links production core code into a test executable. A dedicated loader smoke test also exercises +the actual instrumented module DLL inside a controlled host process; real FAR/Observer is not a test dependency. + +Windows ASan does not detect memory leaks, and a debug-CRT leak check in the test executable cannot account for all +allocations made by separately linked shipping module DLLs. Leak testing is therefore a separate required layer rather +than an ASan option. + +The `test-leaks` operation runs an optimized x64 Release `/MT` probe against the exact Release module DLLs and uses +UMDH from Windows Debugging Tools to compare process-wide heap snapshots. Before measurement it records the loaded +binary paths and hashes, audits their architecture, imports, and exports, and runs an automatic scenario preflight. The +probe then: + +1. load every module and perform an unmeasured application warm-up; +2. let the first UMDH attachment enable process-local allocation stack collection, avoiding persistent/elevated GFlags + registry state, then run another full workload window before accepting a measured baseline; +3. repeatedly exercise successful operations, malformed input, cancellation, read/write failure, and bounded + large/sparse metadata workloads; +4. repeat the same scenario table in a separate mode that loads and unloads each real DLL on every round; +5. take snapshots after multiple measurement windows and retain diffs as test artifacts; +6. fail when allocation stacks or total live heap bytes show sustained growth across consecutive windows. + +The gate must detect a leak slope rather than require a literal zero-byte snapshot difference: the Windows loader, +CRT, symbol engine, and third-party libraries can retain bounded one-time caches. Any allowance must be narrow, +stack-specific, documented, and stable across repeated windows. Debug-CRT checkpoints may provide faster feedback in +unit tests, but they are not accepted as proof that a complete plugin process is leak-free. + +After the parser-core boundary exists, a Clang ASan plus LeakSanitizer job on a supported non-Windows host should check +the same core tests and fuzz regression corpus. It complements UMDH rather than replacing it: LeakSanitizer covers the +portable production logic, while UMDH covers the shipped Windows DLLs, static CRT instances, ABI adapter, and loader +lifecycle. + +Leak freedom and bounded memory consumption are different requirements. A separate memory-budget stress test should +measure peak private bytes while processing synthetic large/sparse archives and verify that archive contents are not +buffered wholesale. The external real-world corpus remains useful as an opt-in local compatibility and stress layer, +but the required local leak gate uses small, repository-owned fixtures and finishes deterministically. + +Fuzzers are standalone executables. They never fuzz through the FAR process. The initial target is the Ren'Py Pickle +parser; archive-index, path, and decompression fuzzers are added as parsing is separated from filesystem I/O. The local +gate replays every checked-in seed and then runs Pickle, Ren'Py, RPG Maker, and Zanzarah independently. Checked-in seeds +include minimized regression inputs and compact representative structures derived from the opt-in external corpus; +the external archives themselves are not committed. Every crash is minimized and committed as a deterministic +regression input after triage. + +## Output and cleanup + +`out/cas` contains immutable successful node results plus markerless directories left by failed or cancelled nodes. +`out/work` contains in-flight scratch space, quarantined incomplete entries, failed-run scratch, and `.locks`. +Successful node scratch is removed immediately. Each active execution holds a run lease; `clean` takes a coordination +lock and refuses to race active runs or node publishers. + +`clean -CleanMode stale-work` removes only inactive scratch. `clean -CleanMode all` removes CAS entries, completed run +data, and inactive locks while retaining the minimal coordination-lock skeleton. Both modes validate the exact +repository `out` layout and reject reparse points, unsafe types, and unexpected top-level/work/lock entries. + +CI uses the narrower `-PruneCas` contract instead of `clean`: complete UIDs from the full current command graph are the +mark set, and canonical CAS directories outside that set are candidates for removal whether complete or markerless. +The sweep takes the global coordination lock, refuses to run while another run lease is active, takes each candidate +node lock, and revalidates its confined directory under that lock. The caller's explicitly identified, verified-active +session lease is the sole exception. The sweep skips locked candidates, preserves noncanonical entries, and rejects an +unsafe layout. + +After the portable parser core is complete, revisit a separate local WSL2 workflow for Linux-only sanitizers and +test-quality experiments. It is not part of the current build graph; shipping modules remain Windows/MSVC artifacts. + +## Release audit and packaging + +Before packaging, `dumpbin` verifies: + +- the expected x86, x64, or ARM64 PE machine type; +- exactly the Observer exports `LoadSubModule` and `UnloadSubModule`; +- absence of `VCRUNTIME`, `MSVCP`, UCRT, zlib, xxHash, ASan, and other non-system DLL dependencies. + +Packaging is rejected if unexpected DLLs, import libraries, or intermediate files enter the staging directory. PDBs +are published separately from module archives. diff --git a/docs/critical-software-methodology.md b/docs/critical-software-methodology.md new file mode 100644 index 0000000..ecc40cc --- /dev/null +++ b/docs/critical-software-methodology.md @@ -0,0 +1,164 @@ +# High-assurance software methodology + +Status: proposed repository policy, accepted principles with several owner decisions still open. + +ObserverModules parses untrusted, sometimes very large archives inside another application's process. A malformed +input, memory leak, ABI violation, or unbounded operation can therefore corrupt or exhaust the host. The project adopts +a **critical-software-inspired** engineering method to reduce that risk. This is not a claim of formal MISRA, +DO-178C, IEC 61508, or other safety certification: the project does not currently have an independent verification +organization, a certified toolchain, or the complete requirements-to-binary evidence such a claim would require. + +## Non-negotiable policy + +1. **Requirements and invariants come before implementation.** Each change identifies its observable behavior, + failure behavior, input limits, ownership, and ABI impact. Safety-relevant assumptions must be executable as tests + or explicit assertions where practical. +2. **Strict TDD is the default change protocol.** First produce a focused test that fails for the expected reason, + then make the smallest production change, then refactor with the suite green. Every defect starts with a regression + test. A test that executes a branch without checking behavior is not sufficient. +3. **All first-party production code has 100% line and branch coverage.** Coverage is a necessary completeness signal, + not proof of correctness. Dangerous compound decisions additionally require executable data-driven decision tables + and targeted MC/DC reasoning so each independent condition is shown to affect the result. +4. **Architecture boundaries are enforced.** Observer/FAR and Win32 integration are outer adapters. Archive operations + and format parsers are inner policy. Dependencies point inward; parser code must be independently testable without + loading FAR, Observer, or Win32 UI infrastructure. +5. **The C/C++ boundary is explicit and hostile by default.** It exposes only stable C-compatible layouts, functions, + result codes, and documented ownership. No C++ exception, STL type, RTTI identity, allocator responsibility, or + implicit lifetime crosses the ABI. +6. **C++ resources use deterministic ownership.** Prefer values and standard containers. Every acquired resource is + immediately owned by an RAII handle. Application code contains no naked owning allocation or release. Raw pointers + are non-owning; `std::unique_ptr` is the default polymorphic owner, while `std::shared_ptr` is exceptional and must + express a genuinely shared lifetime. +7. **All work caused by input is bounded.** A parser defines and checks maximum sizes, counts, nesting depth, allocation + budget, and progress conditions before doing expensive work. Integer calculations are checked before narrowing, + seeking, allocating, or indexing. Input-driven recursion is replaced by iteration or given a strict depth bound. + There is no arbitrary whole-archive size ceiling: multi-gigabyte archives are legitimate. Declared structural + fields must fit the actual input, paths must fit the public ABI, and expanded metadata/index data receives a + separately configurable budget so a compact decompression bomb cannot exhaust the host process. +8. **Failures are deterministic and fail closed.** Partial output is not reported as success. Cancellation, callback + failure, malformed input, exhaustion, and I/O errors have tested outcomes. Outermost ABI functions validate inputs, + initialize outputs, catch only at the boundary, and translate failures to the documented result contract. +9. **Evidence is produced by the exact deliverable.** Unit and parser tests may use seams, but ABI integration, + import/export audit, packaging smoke tests, and leak tests exercise the MSVC Release DLLs that are shipped. +10. **A green gate is never manufactured.** Threshold reductions, first-party exclusions, broad suppressions, swallowed + sanitizer failures, or catch-all fuzz targets are prohibited. Any necessary deviation is narrow, justified, + time-bounded where appropriate, and owner-reviewed. + +## C ABI contract + +Every exported function and callback must satisfy all of the following: + +- use `extern "C"`, an explicit calling convention, fixed-width or ABI-defined types, and fixed-layout structures; +- version extensible structures with `StructSize` or an equivalent explicit size contract; +- validate every pointer, buffer length, structure size, enum/range value, and callback before dereference or call; +- initialize output structures and handles before any operation that can fail; +- document who owns every buffer and handle, how long borrowed data remains valid, and who releases a resource; +- never allocate in one CRT and require another module to deallocate it; +- prohibit exceptions escaping either exported functions or host callbacks; translate internal failures once at the + outer boundary and keep that mapping covered by ABI tests; +- preserve the existing symbol names, calling convention, layouts, and result semantics unless an explicitly reviewed + ABI version change is made. + +Compatibility is checked at both source and binary levels: compile-time layout assertions, real-DLL contract tests, +exact export allowlists, import audits, and package smoke tests. + +## C++ ownership and resource rules + +- Prefer values, `std::vector`, `std::string`, and scoped resource wrappers. +- Use `std::span`/`std::string_view` for checked borrowed ranges and references for required non-null objects. +- Use `std::unique_ptr` only when value semantics do not fit, normally for polymorphism or optional ownership. +- Use `std::shared_ptr` only when no single owner can be identified; record the lifetime reason in the design review. +- Wrap `FILE*`, Win32 `HANDLE`/`HMODULE`, archive streams, zlib state, and temporary-file cleanup in move-only RAII + types with non-throwing destructors. +- Do not call owning `new`, `delete`, `malloc`, `calloc`, `realloc`, or `free` in application C++. Placement new inside + a reviewed low-level resource abstraction is a possible deviation, not a general exception. +- Destructors and cleanup paths must not throw. Move operations should be `noexcept` when their members permit it. +- Avoid mutable globals. If shared state is unavoidable, define its lifetime, synchronization, and reset behavior for + repeated module load/unload cycles. + +These rules follow the C++ Core Guidelines resource-management model: automatic resource handles and RAII, raw +pointers as non-owning views, no naked `new`/`delete`, and `unique_ptr` preferred over shared ownership. + +## Parser safety case + +Each supported format gets a small safety case in tests and, as the parser core is extracted, in its module-level +documentation. At minimum it answers: + +- What identifies the format, and how are truncated or contradictory headers rejected? +- What are the maximum accepted archive size, entry count, path length, nesting depth, metadata/index size, and + decompressed size? Which limits come from the format and which are defensive project limits? +- Which additions, multiplications, casts, seeks, and range calculations can overflow or leave the input bounds? +- Can every loop demonstrate progress and a finite upper bound? Can decompression or parsing amplify tiny input into + excessive CPU, memory, disk, or output? +- How are path traversal, absolute paths, device names, alternate separators, duplicate names, and Unicode conversion + handled before extraction? +- What happens on cancellation, callback failure, short read/write, close/flush failure, partial output, and host + unload/reload? + +Required tests include valid minimal and representative archives, every error category, zero/one/maximum boundaries, +one-past-limit cases, truncation at meaningful byte positions, arithmetic edges, callback failures, cancellation, and +resource cleanup after every failure path. Multi-gigabyte external corpora remain optional compatibility/stress input; +small generated repository fixtures are the deterministic local contract. + +## Verification ladder + +Every layer finds a different defect class; passing one does not substitute for another. + +1. **Fast deterministic tests:** parser/unit tests, common archive-operation tests, and ABI contract tests. +2. **Structural coverage:** 100% LLVM line and branch coverage over first-party production code, plus review of tests + that reach each branch. +3. **Exact-toolchain tests:** MSVC Debug and shippable MSVC Release on runnable x86 and x64 targets; ARM64 is + cross-built, analyzed, audited, and packaged, with unavailable runtime checks reported explicitly as deferred. +4. **Static analysis:** compiler warnings-as-errors, MSVC `/analyze`, clang-tidy, Cppcheck, and PowerShell analysis. + Diagnostics are fixed or narrowly justified, never globally muted. Additional services such as CodeQL may repeat or + extend this evidence but cannot replace a local gate. +5. **Dynamic analysis:** MSVC AddressSanitizer where supported; clang-cl AddressSanitizer and + UndefinedBehaviorSanitizer as independent diagnostic builds; UMDH across repeated real-DLL operations and repeated + load/unload cycles. Peak private bytes and resource budgets are checked separately from leak growth. +6. **Coverage-guided fuzzing:** every parser and its meaningful decoding surfaces receive libFuzzer targets, curated + seed corpora, bounded input/resources, persisted crash artifacts, and regression tests for every confirmed defect. +7. **Binary and package assurance:** exact exports, forbidden imports, `/MT` runtime audit, BinSkim, PDB archive audit, + archive-content allowlists, and smoke tests of the packaged DLL bytes. +8. **Release evidence:** clean-checkout build, pinned dependencies, full gate results, hashes/artifacts, and a reviewed + decision/deviation log. + +Coverage and fuzzing must not catch allocation exhaustion or unexpected exceptions merely to keep running. A crash, +sanitizer report, timeout, leak, or resource-budget violation is a finding and becomes a minimized regression test. + +## Change protocol and traceability + +For each non-trivial change, preserve this chain in the issue/commit, test names, and reports: + +`requirement or hazard -> failing test -> implementation -> coverage -> analysis -> dynamic/fuzz evidence -> binary` + +The repository does not need bureaucratic documents for trivial refactors, but a reviewer must be able to answer why +the change exists, which failure it prevents, which test demonstrates it, which binary contains it, and whether it +changes an ABI, parser limit, dependency, or accepted risk. + +Security- and reliability-relevant deviations are recorded beside the affected code/configuration and in the owning +issue or commit. Analyzer suppressions include the rule, exact scope, rationale, and a test or other evidence that +covers the residual risk. + +## Standards adapted, not claimed + +- **C++ Core Guidelines:** normative baseline for ownership, RAII, interfaces, bounds-aware views, and simplicity. +- **SEI CERT C++:** secure-coding review source for declarations, integers, containers, strings, memory, I/O, error + handling, object lifetime, concurrency, and miscellaneous security hazards. +- **JPL Power of Ten:** adopt reviewable control flow, bounded loops, no unbounded recursion, small cohesive functions, + assertions/contracts, minimal preprocessor use, and warnings/static analysis. Its strict C-oriented prohibition on + dynamic allocation is adapted to bounded RAII allocation because archive metadata is inherently variable-sized. +- **MISRA and formal safety standards:** they can inspire individual engineering practices, but compliance and + certification are explicitly out of scope. The repository will not maintain a MISRA profile or claim DO-178C, + IEC 61508, ISO 26262, or similar status. + +## Decisions to settle with the owner + +1. **Internal error model:** keep typed C++ exceptions inside the core and translate them only at the ABI, or migrate + fallible parser/application operations toward an explicit result type such as `std::expected`? Either choice must + preserve RAII and prohibit exceptions crossing the C boundary. +2. **Resource budgets:** choose concrete archive, entry-count, path, index, decompressed-output, nesting, CPU/time, and + memory ceilings per format, including whether callers may configure them. +3. **Shared ownership:** forbid `std::shared_ptr` entirely in first-party code unless an ADR is approved, or permit it + with a local lifetime rationale? +4. **Release provenance:** whether reproducible-build comparison, SBOM, signing, and SLSA-style provenance become + mandatory release gates. diff --git a/licenses/IX.txt b/licenses/IX.txt new file mode 100644 index 0000000..6432aae --- /dev/null +++ b/licenses/IX.txt @@ -0,0 +1,22 @@ +IX build system +Source: https://github.com/pg83/ix +Revision: 66726a904152246fbef8b27e26e878840f6d7fb7 + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/licenses/zstr.txt b/licenses/zstr.txt deleted file mode 100644 index 3c33ea6..0000000 --- a/licenses/zstr.txt +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2015 Matei David, Ontario Institute for Cancer Research - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/api.h b/src/api.h index 0fa0292..c7ff66f 100644 --- a/src/api.h +++ b/src/api.h @@ -23,16 +23,6 @@ It includes/modifies code originally from Observer (https://github.com/lazyhamst #define _WIN32_WINNT 0x0600 #endif -#ifndef _WIN32_WINDOWS -// Specifies that the minimum required platform is Windows 98. -#define _WIN32_WINDOWS 0x0410 -#endif - -#ifndef _WIN32_IE -// Specifies that the minimum required platform is Internet Explorer 7.0. -#define _WIN32_IE 0x0700 -#endif - // Exclude rarely-used stuff from Windows headers. #define WIN32_LEAN_AND_MEAN @@ -41,7 +31,7 @@ It includes/modifies code originally from Observer (https://github.com/lazyhamst #define MODULE_EXPORT __stdcall // Extract progress callbacks -typedef int (CALLBACK *ExtractProgressFunc)(HANDLE, __int64); +typedef int(CALLBACK *ExtractProgressFunc)(HANDLE, __int64); #pragma pack(push, 1) @@ -93,15 +83,15 @@ struct ExtractOperationParams ExtractProcessCallbacks Callbacks; }; -typedef int (MODULE_EXPORT *OpenStorageFunc)(StorageOpenParams params, HANDLE *storage, StorageGeneralInfo *info); +typedef int(MODULE_EXPORT *OpenStorageFunc)(StorageOpenParams params, HANDLE *storage, StorageGeneralInfo *info); -typedef int (MODULE_EXPORT *PrepareFilesFunc)(HANDLE storage); +typedef int(MODULE_EXPORT *PrepareFilesFunc)(HANDLE storage); -typedef void (MODULE_EXPORT *CloseStorageFunc)(HANDLE storage); +typedef void(MODULE_EXPORT *CloseStorageFunc)(HANDLE storage); -typedef int (MODULE_EXPORT *GetItemFunc)(HANDLE storage, int item_index, StorageItemInfo *item_info); +typedef int(MODULE_EXPORT *GetItemFunc)(HANDLE storage, int item_index, StorageItemInfo *item_info); -typedef int (MODULE_EXPORT *ExtractFunc)(HANDLE storage, ExtractOperationParams params); +typedef int(MODULE_EXPORT *ExtractFunc)(HANDLE storage, ExtractOperationParams params); struct module_cbs { @@ -114,10 +104,10 @@ struct module_cbs struct ModuleLoadParameters { - //IN + // IN size_t StructSize; const wchar_t *Settings; - //OUT + // OUT GUID ModuleId; DWORD ModuleVersion; DWORD ApiVersion; @@ -127,12 +117,12 @@ struct ModuleLoadParameters #pragma pack(pop) // Function that should be exported from modules -typedef int (MODULE_EXPORT *LoadSubModuleFunc)(ModuleLoadParameters *); +typedef int(MODULE_EXPORT *LoadSubModuleFunc)(ModuleLoadParameters *); -typedef void (MODULE_EXPORT *UnloadSubModuleFunc)(void); +typedef void(MODULE_EXPORT *UnloadSubModuleFunc)(void); -#define MAKEMODULEVERSION(mj,mn) ((mj << 16) | mn) -#define STRBUF_SIZE(x) ( sizeof(x) / sizeof(x[0]) ) +#define MAKEMODULEVERSION(mj, mn) (((mj) << 16) | (mn)) +#define STRBUF_SIZE(x) (sizeof(x) / sizeof((x)[0])) // Open storage return results #define SOR_INVALID_FILE 0 diff --git a/src/archive.cpp b/src/archive.cpp index e284497..f5e38d1 100644 --- a/src/archive.cpp +++ b/src/archive.cpp @@ -9,9 +9,8 @@ namespace archive { - archive::archive(std::unique_ptr extractor) + archive::archive(std::unique_ptr extractor) : extractor_(std::move(extractor)) { - extractor_ = std::move(extractor); } bool starts_with_bytes(std::span data, std::span signature) noexcept @@ -50,7 +49,7 @@ namespace archive throw read_error(); } - for (const auto &file: files_) { + for (const auto &file : files_) { std::ranges::replace(file->path, '/', '\\'); } } @@ -73,46 +72,42 @@ namespace archive } output.exceptions(std::ofstream::failbit | std::ofstream::badbit); - constexpr int64_t buffer_size = 128 * 1024; + constexpr int64_t buffer_size = 128LL * 1024; std::vector buffer(buffer_size); - if (!file.header.empty()) { - output.write(file.header.data(), std::ssize(file.header)); - } - try { - stream_->seekg(file.offset); - } catch (std::ios_base::failure &) { - throw read_error(); - } - - uint32_t magic = file.magic; - int64_t bytes_left = file.compressed_body_size_in_bytes; - while (bytes_left > 0) { - const auto chunk_size = static_cast(std::min(bytes_left, buffer_size)); + if (!file.header.empty()) { + output.write(file.header.data(), std::ssize(file.header)); + } try { - stream_->read(buffer.data(), chunk_size); + stream_->seekg(file.offset); } catch (std::ios_base::failure &) { throw read_error(); } - buffer.resize(static_cast(chunk_size)); - magic = extractor_->decrypt(magic, buffer); + uint32_t magic = file.magic; + int64_t bytes_left = file.compressed_body_size_in_bytes; + while (bytes_left > 0) { + const auto chunk_size = static_cast(std::min(bytes_left, buffer_size)); - try { - output.write(buffer.data(), buffer.size()); - } catch (std::ios_base::failure &) { - throw write_error(); - } + try { + stream_->read(buffer.data(), chunk_size); + } catch (std::ios_base::failure &) { + throw read_error(); + } - bytes_left -= chunk_size; + buffer.resize(static_cast(chunk_size)); + magic = extractor_->decrypt(magic, buffer); + output.write(buffer.data(), static_cast(buffer.size())); - try { + bytes_left -= chunk_size; report_progress(chunk_size); - } catch (user_interrupt &) { - return; } + + output.close(); + } catch (std::ios_base::failure &) { + throw write_error(); } } -} +} // namespace archive diff --git a/src/archive.h b/src/archive.h index 86fdecf..04ed8d6 100644 --- a/src/archive.h +++ b/src/archive.h @@ -11,31 +11,31 @@ namespace archive { class read_error final : public std::runtime_error { - public: - read_error(): runtime_error("") + public: + read_error() : runtime_error("") { } }; class write_error final : public std::runtime_error { - public: - write_error(): runtime_error("") + public: + write_error() : runtime_error("") { } }; class user_interrupt final : public std::runtime_error { - public: - user_interrupt(): runtime_error("") + public: + user_interrupt() : runtime_error("") { } }; class archive final { - public: + public: explicit archive(std::unique_ptr extractor); extractor::archive_info open(const std::filesystem::path &path, const std::span &data); @@ -47,9 +47,9 @@ namespace archive void extract_file(size_t index, const std::filesystem::path &path, const std::function &report_progress) const; - private: + private: std::unique_ptr extractor_; std::unique_ptr stream_; - std::vector > files_; + std::vector> files_; }; -} +} // namespace archive diff --git a/src/core/archive_limits.h b/src/core/archive_limits.h new file mode 100644 index 0000000..2f48261 --- /dev/null +++ b/src/core/archive_limits.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace observer::archive_limits +{ + // The Observer ABI exposes 1024 UTF-16 code units. Four UTF-8 bytes per usable code unit is a safe allocation cap. + inline constexpr std::size_t max_path_bytes = std::size_t{4} * (1024 - 1); + + // The repository corpus currently peaks at 2,205 entries. This leaves ample compatibility headroom while keeping + // a corrupt count from driving an unbounded allocation. + inline constexpr std::size_t max_entry_count = 100'000; +} // namespace observer::archive_limits diff --git a/src/core/compression/zlib_codec.cpp b/src/core/compression/zlib_codec.cpp new file mode 100644 index 0000000..86aa6bd --- /dev/null +++ b/src/core/compression/zlib_codec.cpp @@ -0,0 +1,92 @@ +#include "zlib_codec.h" + +#include +#include + +#include + +namespace observer::compression +{ + namespace + { + class inflate_context final + { + public: + inflate_context() + { + if (inflateInit(&stream_) != Z_OK) { + throw error("Failed to initialize zlib decompression"); + } + } + + ~inflate_context() + { + static_cast(inflateEnd(&stream_)); + } + + inflate_context(const inflate_context &) = delete; + inflate_context &operator=(const inflate_context &) = delete; + inflate_context(inflate_context &&) = delete; + inflate_context &operator=(inflate_context &&) = delete; + + [[nodiscard]] z_stream &stream() noexcept + { + return stream_; + } + + private: + z_stream stream_{}; + }; + + [[noreturn]] void throw_zlib_error(const char *operation, const z_stream &stream) + { + auto message = std::string(operation); + if (stream.msg != nullptr) { + message.append(": ").append(stream.msg); + } + throw error(message); + } + } // namespace + + std::vector decompress_zlib(const std::span input, const std::size_t max_output_bytes) + { + inflate_context context; + auto &stream = context.stream(); + std::vector output; + std::vector chunk(std::size_t{64} * 1024); + std::size_t input_offset = 0; + + while (true) { + if (stream.avail_in == 0 && input_offset < input.size()) { + const auto input_size = + std::min(input.size() - input_offset, static_cast(std::numeric_limits::max())); + stream.next_in = reinterpret_cast(const_cast(input.data() + input_offset)); + stream.avail_in = static_cast(input_size); + input_offset += input_size; + } + + stream.next_out = reinterpret_cast(chunk.data()); + stream.avail_out = static_cast(chunk.size()); + const auto result = inflate(&stream, Z_NO_FLUSH); + const auto produced = chunk.size() - stream.avail_out; + if (produced > max_output_bytes - output.size()) { + throw error("zlib output exceeds the configured metadata budget"); + } + output.insert(output.end(), chunk.begin(), chunk.begin() + static_cast(produced)); + + if (result == Z_STREAM_END) { + const auto consumed_input = input_offset - stream.avail_in; + if (consumed_input != input.size()) { + throw error("Trailing data after zlib stream"); + } + return output; + } + if (result == Z_BUF_ERROR) { + throw error("Truncated zlib stream"); + } + if (result != Z_OK) { + throw_zlib_error("zlib decompression failed", stream); + } + } + } +} // namespace observer::compression diff --git a/src/core/compression/zlib_codec.h b/src/core/compression/zlib_codec.h new file mode 100644 index 0000000..5071d2c --- /dev/null +++ b/src/core/compression/zlib_codec.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include +#include + +namespace observer::compression +{ + class error final : public std::runtime_error + { + public: + using std::runtime_error::runtime_error; + }; + + [[nodiscard]] std::vector decompress_zlib(std::span input, + std::size_t max_output_bytes); +} // namespace observer::compression diff --git a/src/core/io/bounded_stream.cpp b/src/core/io/bounded_stream.cpp new file mode 100644 index 0000000..bcaa4cb --- /dev/null +++ b/src/core/io/bounded_stream.cpp @@ -0,0 +1,77 @@ +#include "bounded_stream.h" + +#include + +namespace observer::io +{ + bounded_stream::bounded_stream(std::istream &stream) : stream_(stream) + { + try { + const auto original = stream_.tellg(); + stream_.seekg(0, std::ios::end); + const auto end = stream_.tellg(); + stream_.seekg(original); + if (original < 0 || end < original) { + throw read_error(); + } + size_ = static_cast(end); + } catch (const std::ios_base::failure &) { + throw read_error(); + } + } + + std::streamoff bounded_stream::size() const noexcept + { + return size_; + } + + std::streamoff bounded_stream::position() + { + try { + const auto result = stream_.tellg(); + if (result < 0 || result > size_) { + throw read_error(); + } + return static_cast(result); + } catch (const std::ios_base::failure &) { + throw read_error(); + } + } + + std::streamoff bounded_stream::remaining() + { + return size_ - position(); + } + + void bounded_stream::seek_absolute(const std::streamoff offset) + { + if (offset < 0 || offset > size_) { + throw read_error(); + } + try { + stream_.seekg(offset); + } catch (const std::ios_base::failure &) { + throw read_error(); + } + if (!stream_) { + throw read_error(); + } + } + + void bounded_stream::read_exact(char *destination, const std::size_t byte_count) + { + if (byte_count > static_cast(std::numeric_limits::max()) || + byte_count > static_cast(remaining())) { + throw read_error(); + } + const auto stream_size = static_cast(byte_count); + try { + stream_.read(destination, stream_size); + } catch (const std::ios_base::failure &) { + throw read_error(); + } + if (stream_.gcount() != stream_size) { + throw read_error(); + } + } +} // namespace observer::io diff --git a/src/core/io/bounded_stream.h b/src/core/io/bounded_stream.h new file mode 100644 index 0000000..d5b487c --- /dev/null +++ b/src/core/io/bounded_stream.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace observer::io +{ + class read_error final : public std::runtime_error + { + public: + read_error() : std::runtime_error("bounded stream read failed") + { + } + }; + + class bounded_stream final + { + public: + explicit bounded_stream(std::istream &stream); + + [[nodiscard]] std::streamoff size() const noexcept; + [[nodiscard]] std::streamoff position(); + [[nodiscard]] std::streamoff remaining(); + void seek_absolute(std::streamoff offset); + void read_exact(char *destination, std::size_t byte_count); + + template + requires std::is_trivially_copyable_v + [[nodiscard]] Value read_trivial() + { + Value value{}; + read_exact(reinterpret_cast(&value), sizeof(value)); + return value; + } + + private: + std::istream &stream_; + std::streamoff size_ = 0; + }; +} // namespace observer::io diff --git a/src/dll.cpp b/src/dll.cpp index 389606f..9a70619 100644 --- a/src/dll.cpp +++ b/src/dll.cpp @@ -7,16 +7,38 @@ #include #include -void copy_string(const std::wstring &source, wchar_t *destination, const std::size_t max_len) +#ifdef _DEBUG +#include +#include +#endif + +namespace { - if (wcscpy_s(destination, max_len, source.c_str()) != 0) { - throw std::runtime_error("CopyString failed"); +#ifdef _DEBUG + void configure_debug_crt() noexcept + { + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR); + _CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR); + _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR); } +#endif +} // namespace + +void copy_string(const std::wstring &source, wchar_t *destination, const std::size_t max_len) noexcept +{ + static_cast(wcsncpy_s(destination, max_len, source.c_str(), _TRUNCATE)); } extern "C" int MODULE_EXPORT OpenStorage(StorageOpenParams params, HANDLE *storage, StorageGeneralInfo *info) { - if (storage == nullptr) return SOR_INVALID_FILE; + if (storage == nullptr || info == nullptr || params.FilePath == nullptr) + return SOR_INVALID_FILE; + + *storage = nullptr; const std::filesystem::path path(params.FilePath); @@ -58,7 +80,7 @@ extern "C" int MODULE_EXPORT PrepareFiles(HANDLE storage) return FALSE; } - const auto archive = static_cast(storage); + auto *archive = static_cast(storage); try { archive->prepare_files(); } catch (std::runtime_error &) { @@ -72,11 +94,11 @@ extern "C" int MODULE_EXPORT PrepareFiles(HANDLE storage) extern "C" int MODULE_EXPORT GetItem(HANDLE storage, int item_index, StorageItemInfo *item_info) { - if (storage == nullptr || item_index < 0) { + if (storage == nullptr || item_index < 0 || item_info == nullptr) { return GET_ITEM_ERROR; } - const auto archive = static_cast(storage); + const auto *archive = static_cast(storage); try { const auto &file = archive->get_file(item_index); const auto header_size = std::ssize(file.header); @@ -93,28 +115,25 @@ extern "C" int MODULE_EXPORT GetItem(HANDLE storage, int item_index, StorageItem } } catch (std::out_of_range &) { return GET_ITEM_NOMOREITEMS; - } catch (std::runtime_error &) { - return GET_ITEM_ERROR; } return GET_ITEM_OK; } -// ReSharper disable once CppParameterMayBeConst extern "C" int MODULE_EXPORT ExtractItem(HANDLE storage, ExtractOperationParams params) { - if (storage == nullptr || params.ItemIndex < 0 || params.DestPath == nullptr) { + if (storage == nullptr || params.ItemIndex < 0 || params.DestPath == nullptr || + params.Callbacks.FileProgress == nullptr) { return SER_ERROR_SYSTEM; } - auto report_progress = [callbacks = params.Callbacks](const int64_t bytes_read) - { + auto report_progress = [callbacks = params.Callbacks](const int64_t bytes_read) { if (!callbacks.FileProgress(callbacks.signalContext, bytes_read)) { throw archive::user_interrupt(); } }; - const auto archive = static_cast(storage); + const auto *archive = static_cast(storage); try { archive->extract_file(params.ItemIndex, params.DestPath, report_progress); } catch (archive::user_interrupt &) { @@ -134,6 +153,13 @@ extern "C" int MODULE_EXPORT ExtractItem(HANDLE storage, ExtractOperationParams extern "C" int MODULE_EXPORT LoadSubModule(ModuleLoadParameters *LoadParams) noexcept { +#ifdef _DEBUG + configure_debug_crt(); +#endif + if (LoadParams == nullptr) { + return FALSE; + } + const auto [id, major_version, minor_version] = extractor::get_version_info(); const auto [id1, id2, id3, id4] = id; LoadParams->ModuleId = {id1, id2, id3, {id4[0], id4[1], id4[2], id4[3], id4[4], id4[5], id4[6], id4[7]}}; diff --git a/src/fuzz/archive.cpp b/src/fuzz/archive.cpp new file mode 100644 index 0000000..bc4fbac --- /dev/null +++ b/src/fuzz/archive.cpp @@ -0,0 +1,51 @@ +#include "../modules/extractor.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + std::uint32_t initial_magic(const std::uint8_t *data, const std::size_t size) noexcept + { + std::uint32_t magic = 0; + if (size > 0) { + std::memcpy(&magic, data, std::min(size, sizeof(magic))); + } + return magic; + } +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, const std::size_t size) +{ + extractor::extractor parser; + + std::vector body(size); + if (size > 0) { + std::memcpy(body.data(), data, size); + } + static_cast(parser.decrypt(initial_magic(data, size), body)); + + const std::string bytes(reinterpret_cast(data), size); + std::istringstream stream(bytes, std::ios::in | std::ios::binary); + + try { + static_cast(parser.list_files(stream)); + } catch (const std::invalid_argument &) { + // Invalid numeric fields are expected. + return 0; + } catch (const std::out_of_range &) { + // Invalid numeric fields are expected. std::length_error is deliberately not caught. + return 0; + } catch (const std::runtime_error &) { + // Malformed/truncated archive input is expected. Allocation failures must still escape. + return 0; + } + + return 0; +} diff --git a/src/fuzz/corpus/pickle/external-catch-canvas-index.hex b/src/fuzz/corpus/pickle/external-catch-canvas-index.hex new file mode 100644 index 0000000..b66d546 --- /dev/null +++ b/src/fuzz/corpus/pickle/external-catch-canvas-index.hex @@ -0,0 +1 @@ +80027d710128581a000000696d616765732f6367732f657374656c6c655f7365782e6a70675d71028a041bba62424aad6c5242550087710361581e000000696d616765732f6367732f657374656c6c65626174685f7365782e6a70675d71048a04594d51424a6fab4f425500877105615816000000696d616765732f6367732f6e616f5f7365782e6a706771065d71078a041b6573424a481f49425500877108615817000000696d616765732f6367732f6461776e5f7365782e6a70675d71098a04714242424a954c5142550087710a61752e diff --git a/src/fuzz/corpus/pickle/invalid-empty-int.pickle b/src/fuzz/corpus/pickle/invalid-empty-int.pickle new file mode 100644 index 0000000..db1a5a0 --- /dev/null +++ b/src/fuzz/corpus/pickle/invalid-empty-int.pickle @@ -0,0 +1 @@ +I diff --git a/src/fuzz/corpus/pickle/invalid-mark-position.hex b/src/fuzz/corpus/pickle/invalid-mark-position.hex new file mode 100644 index 0000000..437c486 --- /dev/null +++ b/src/fuzz/corpus/pickle/invalid-mark-position.hex @@ -0,0 +1 @@ +5d4e28616c diff --git a/src/fuzz/corpus/pickle/none.pickle b/src/fuzz/corpus/pickle/none.pickle new file mode 100644 index 0000000..f0e2152 --- /dev/null +++ b/src/fuzz/corpus/pickle/none.pickle @@ -0,0 +1 @@ +N. diff --git a/src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex b/src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex new file mode 100644 index 0000000..16e68d1 --- /dev/null +++ b/src/fuzz/corpus/renpy/external-catch-canvas-nao-prefix.hex @@ -0,0 +1 @@ +5250412d332e3020303030303030303030303030303134302034323432343234320a000000000000000000000000000000000000000000000000000000000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff78da6b60aa2d64d48810636060c8cc4d4c4f2dd64f4e2fd6cf4bcc8f2f4eadd0cb2a488f2d64ea6261727272f2727276720a65682f644e2cd50300c4ce1042 diff --git a/src/fuzz/corpus/renpy/valid-rpa2.hex b/src/fuzz/corpus/renpy/valid-rpa2.hex new file mode 100644 index 0000000..5daa12f --- /dev/null +++ b/src/fuzz/corpus/renpy/valid-rpa2.hex @@ -0,0 +1 @@ +5250412d322e3020303030303030303030303030303032310a00000000000000627801ab0d654c8cf556f0666c4b2cd603001aca03d1 diff --git a/src/fuzz/corpus/rpgmaker/declared-name-overflow.hex b/src/fuzz/corpus/rpgmaker/declared-name-overflow.hex new file mode 100644 index 0000000..5deed84 --- /dev/null +++ b/src/fuzz/corpus/rpgmaker/declared-name-overflow.hex @@ -0,0 +1 @@ +52 47 53 53 41 44 00 03 01 00 00 00 0d 00 00 00 0d 00 00 00 0d 00 00 00 f3 ff ff ff diff --git a/src/fuzz/corpus/rpgmaker/external-smallest-entry.hex b/src/fuzz/corpus/rpgmaker/external-smallest-entry.hex new file mode 100644 index 0000000..f7833e9 --- /dev/null +++ b/src/fuzz/corpus/rpgmaker/external-smallest-entry.hex @@ -0,0 +1 @@ +52475353414400032b440000b9650200a66502009915020099650200c1176370ee0c6173da266a61f4046174e317715ca23c676cea0a752ed62b4586650200000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f diff --git a/src/fuzz/corpus/rpgmaker/valid-rgss3a.hex b/src/fuzz/corpus/rpgmaker/valid-rgss3a.hex new file mode 100644 index 0000000..1a9f067 --- /dev/null +++ b/src/fuzz/corpus/rpgmaker/valid-rgss3a.hex @@ -0,0 +1 @@ +5247535341440003010000002d0000000d000000745634120d0000006d0c0000001a diff --git a/src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex b/src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex new file mode 100644 index 0000000..290172c --- /dev/null +++ b/src/fuzz/corpus/zanzarah/declared-entry-count-overflow.hex @@ -0,0 +1 @@ +00 00 00 00 ff ff ff 7f diff --git a/src/fuzz/corpus/zanzarah/declared-path-overflow.hex b/src/fuzz/corpus/zanzarah/declared-path-overflow.hex new file mode 100644 index 0000000..147edbb --- /dev/null +++ b/src/fuzz/corpus/zanzarah/declared-path-overflow.hex @@ -0,0 +1 @@ +00 00 00 00 01 00 00 00 ff ff ff 7f diff --git a/src/fuzz/corpus/zanzarah/external-smallest-entry.hex b/src/fuzz/corpus/zanzarah/external-smallest-entry.hex new file mode 100644 index 0000000..b6c8d4b --- /dev/null +++ b/src/fuzz/corpus/zanzarah/external-smallest-entry.hex @@ -0,0 +1 @@ +0000000001000000280000002e2e5c5245534f55524345535c54455854555245535c4d4f44454c535c48474c303054482e424d50000000001400000000000000000102030405060708090a0b0c0d0e0f diff --git a/src/fuzz/corpus/zanzarah/valid-pak.hex b/src/fuzz/corpus/zanzarah/valid-pak.hex new file mode 100644 index 0000000..99bd93c --- /dev/null +++ b/src/fuzz/corpus/zanzarah/valid-pak.hex @@ -0,0 +1 @@ +0000000001000000010000006100000000050000007856341262 diff --git a/src/fuzz/pickle.cpp b/src/fuzz/pickle.cpp new file mode 100644 index 0000000..4500b4b --- /dev/null +++ b/src/fuzz/pickle.cpp @@ -0,0 +1,29 @@ +#include "../modules/renpy/pickle.h" + +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, const std::size_t size) +{ + const auto bytes = std::span{ + reinterpret_cast(data), + size, + }; + + try { + static_cast(pickle::loads(bytes)); + } catch (const std::invalid_argument &) { + // Invalid numeric Pickle input is expected. + return 0; + } catch (const std::out_of_range &) { + // Invalid numeric Pickle input is expected. std::length_error is deliberately not caught. + return 0; + } catch (const std::runtime_error &) { + // Invalid Pickle structure is expected. Allocation failures must still escape. + return 0; + } + + return 0; +} diff --git a/src/modules/extractor.h b/src/modules/extractor.h index 5b5e566..fa15456 100644 --- a/src/modules/extractor.h +++ b/src/modules/extractor.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -27,8 +28,8 @@ namespace extractor class read_error final : public std::runtime_error { - public: - read_error(): runtime_error("") + public: + read_error() : runtime_error("") { } }; @@ -44,23 +45,23 @@ namespace extractor { std::string path; std::string header; - int64_t offset; - int64_t compressed_body_size_in_bytes; - int64_t uncompressed_body_size_in_bytes; + int64_t offset = 0; + int64_t compressed_body_size_in_bytes = 0; + int64_t uncompressed_body_size_in_bytes = 0; uint32_t magic = 0; }; class extractor { - public: + public: virtual ~extractor() = default; - static std::vector get_signature() noexcept; + static std::vector get_signature(); - archive_info get_archive_info(const std::span &data) noexcept; + archive_info get_archive_info(const std::span &data); - std::vector > list_files(std::ifstream &stream); + std::vector> list_files(std::istream &stream); uint32_t decrypt(uint32_t magic, std::vector &data) const; }; -} +} // namespace extractor diff --git a/src/modules/renpy/pickle.cpp b/src/modules/renpy/pickle.cpp index f3893bf..3ee65b0 100644 --- a/src/modules/renpy/pickle.cpp +++ b/src/modules/renpy/pickle.cpp @@ -1,43 +1,80 @@ #include "pickle.h" +#include +#include +#include +#include + namespace pickle { + namespace + { + list clone_list(const list &source) + { + list result; + result.reserve(source.size()); + std::ranges::transform(source, std::back_inserter(result), [](const auto &item) { return clone(*item); }); + return result; + } + + dict clone_dict(const dict &source) + { + dict result; + result.reserve(source.size()); + for (const auto &[key, item] : source) { + result.emplace(key, clone(*item)); + } + return result; + } + } // namespace + + value_ptr clone(const value &source) + { + switch (source.get_type()) { + case value::type::none: + return value::none(); + case value::type::bool_: + return value::boolean(source.as_bool()); + case value::type::int64: + return value::int64(source.as_int64()); + case value::type::float64: + return value::float64(source.as_float64()); + case value::type::bytes: + return value::bytes(source.as_string()); + case value::type::string: + return value::string(source.as_string()); + case value::type::list: + return value::list(clone_list(source.as_list())); + case value::type::dict: + return value::dict(clone_dict(source.as_dict())); + case value::type::tuple: + return value::tuple(clone_list(source.as_tuple())); + default: + throw std::runtime_error("Unsupported pickle value type"); + } + } + // Pickle opcodes (subset needed for basic functionality) namespace opcodes { constexpr uint8_t MARK = '('; constexpr uint8_t STOP = '.'; - constexpr uint8_t POP = '0'; - constexpr uint8_t POP_MARK = '1'; - constexpr uint8_t DUP = '2'; - constexpr uint8_t FLOAT = 'F'; constexpr uint8_t INT = 'I'; constexpr uint8_t BININT = 'J'; constexpr uint8_t BININT1 = 'K'; constexpr uint8_t BININT2 = 'M'; constexpr uint8_t NONE = 'N'; - constexpr uint8_t PERSID = 'P'; - constexpr uint8_t BINPERSID = 'Q'; - constexpr uint8_t REDUCE = 'R'; - constexpr uint8_t STRING = 'S'; constexpr uint8_t BINSTRING = 'T'; constexpr uint8_t SHORT_BINSTRING = 'U'; - constexpr uint8_t UNICODE = 'V'; constexpr uint8_t BINUNICODE = 'X'; constexpr uint8_t APPEND = 'a'; - constexpr uint8_t BUILD = 'b'; - constexpr uint8_t GLOBAL = 'c'; constexpr uint8_t DICT = 'd'; constexpr uint8_t EMPTY_DICT = '}'; constexpr uint8_t APPENDS = 'e'; - constexpr uint8_t GET = 'g'; constexpr uint8_t BINGET = 'h'; - constexpr uint8_t INST = 'i'; constexpr uint8_t LONG_BINGET = 'j'; constexpr uint8_t LIST = 'l'; constexpr uint8_t EMPTY_LIST = ']'; - constexpr uint8_t OBJ = 'o'; - constexpr uint8_t PUT = 'p'; constexpr uint8_t BINPUT = 'q'; constexpr uint8_t LONG_BINPUT = 'r'; constexpr uint8_t SETITEM = 's'; @@ -48,17 +85,12 @@ namespace pickle // Protocol 2 constexpr uint8_t PROTO = 0x80; - constexpr uint8_t NEWOBJ = 0x81; - constexpr uint8_t EXT1 = 0x82; - constexpr uint8_t EXT2 = 0x83; - constexpr uint8_t EXT4 = 0x84; constexpr uint8_t TUPLE1 = 0x85; constexpr uint8_t TUPLE2 = 0x86; constexpr uint8_t TUPLE3 = 0x87; constexpr uint8_t NEWTRUE = 0x88; constexpr uint8_t NEWFALSE = 0x89; constexpr uint8_t LONG1 = 0x8a; - constexpr uint8_t LONG4 = 0x8b; // Protocol 3 constexpr uint8_t BINBYTES = 'B'; @@ -66,16 +98,9 @@ namespace pickle // Protocol 4 constexpr uint8_t SHORT_BINUNICODE = 0x8c; - constexpr uint8_t BINUNICODE8 = 0x8d; - constexpr uint8_t BINBYTES8 = 0x8e; - constexpr uint8_t EMPTY_SET = 0x8f; - constexpr uint8_t ADDITEMS = 0x90; - constexpr uint8_t FROZENSET = 0x91; - constexpr uint8_t NEWOBJ_EX = 0x92; - constexpr uint8_t STACK_GLOBAL = 0x93; constexpr uint8_t MEMOIZE = 0x94; constexpr uint8_t FRAME = 0x95; - } + } // namespace opcodes uint8_t parser::read_byte() { @@ -90,8 +115,7 @@ namespace pickle if (pos_ + 2 > data_.size()) { throw std::runtime_error("Unexpected end of pickle data"); } - const uint16_t result = static_cast(data_[pos_]) | - static_cast(data_[pos_ + 1]) << 8; + const uint16_t result = static_cast(data_[pos_]) | static_cast(data_[pos_ + 1]) << 8; pos_ += 2; return result; } @@ -101,8 +125,7 @@ namespace pickle if (pos_ + 4 > data_.size()) { throw std::runtime_error("Unexpected end of pickle data"); } - const uint32_t result = static_cast(data_[pos_]) | - static_cast(data_[pos_ + 1]) << 8 | + const uint32_t result = static_cast(data_[pos_]) | static_cast(data_[pos_ + 1]) << 8 | static_cast(data_[pos_ + 2]) << 16 | static_cast(data_[pos_ + 3]) << 24; pos_ += 4; @@ -114,14 +137,11 @@ namespace pickle if (pos_ + 8 > data_.size()) { throw std::runtime_error("Unexpected end of pickle data"); } - const uint64_t result = static_cast(data_[pos_]) | - static_cast(data_[pos_ + 1]) << 8 | - static_cast(data_[pos_ + 2]) << 16 | - static_cast(data_[pos_ + 3]) << 24 | - static_cast(data_[pos_ + 4]) << 32 | - static_cast(data_[pos_ + 5]) << 40 | - static_cast(data_[pos_ + 6]) << 48 | - static_cast(data_[pos_ + 7]) << 56; + const uint64_t result = + static_cast(data_[pos_]) | static_cast(data_[pos_ + 1]) << 8 | + static_cast(data_[pos_ + 2]) << 16 | static_cast(data_[pos_ + 3]) << 24 | + static_cast(data_[pos_ + 4]) << 32 | static_cast(data_[pos_ + 5]) << 40 | + static_cast(data_[pos_ + 6]) << 48 | static_cast(data_[pos_ + 7]) << 56; pos_ += 8; return result; } @@ -161,6 +181,9 @@ namespace pickle throw std::runtime_error("No mark on stack"); } const size_t mark_pos = mark_stack_.back(); + if (mark_pos > stack_.size()) { + throw std::runtime_error("Invalid mark position"); + } mark_stack_.pop_back(); list result; @@ -175,375 +198,344 @@ namespace pickle value_ptr parser::parse_value() { switch (uint8_t opcode = read_byte()) { - case opcodes::MARK: - push_mark(); - break; + case opcodes::MARK: + push_mark(); + break; - case opcodes::STOP: - if (stack_.size() != 1) { - throw std::runtime_error("Invalid stack size at end of pickle"); - } - return std::move(stack_[0]); + case opcodes::STOP: + if (stack_.size() != 1) { + throw std::runtime_error("Invalid stack size at end of pickle"); + } + return std::move(stack_[0]); - case opcodes::NONE: - stack_.push_back(value::none()); - break; + case opcodes::NONE: + stack_.push_back(value::none()); + break; - case opcodes::NEWTRUE: - stack_.push_back(value::boolean(true)); - break; + case opcodes::NEWTRUE: + stack_.push_back(value::boolean(true)); + break; - case opcodes::NEWFALSE: - stack_.push_back(value::boolean(false)); - break; + case opcodes::NEWFALSE: + stack_.push_back(value::boolean(false)); + break; - case opcodes::BININT: - stack_.push_back(value::int64(static_cast(read_uint32_le()))); - break; + case opcodes::BININT: + stack_.push_back(value::int64(static_cast(read_uint32_le()))); + break; - case opcodes::BININT1: - stack_.push_back(value::int64(read_byte())); - break; + case opcodes::BININT1: + stack_.push_back(value::int64(read_byte())); + break; - case opcodes::BININT2: - stack_.push_back(value::int64(read_uint16_le())); - break; + case opcodes::BININT2: + stack_.push_back(value::int64(read_uint16_le())); + break; - case opcodes::INT: - { - std::string int_str = read_line(); - if (int_str.back() == 'L') { - int_str.pop_back(); // Remove trailing L - } - int64_t val = std::stoll(int_str); - stack_.push_back(value::int64(val)); - break; + case opcodes::INT: { + std::string int_str = read_line(); + if (int_str.empty()) { + throw std::runtime_error("Empty INT opcode argument"); } - - case opcodes::BINFLOAT: - { - uint64_t bits = read_uint64_le(); - double val; - std::memcpy(&val, &bits, sizeof(double)); - stack_.push_back(value::float64(val)); - break; + if (int_str.back() == 'L') { + int_str.pop_back(); // Remove trailing L } + int64_t val = std::stoll(int_str); + stack_.push_back(value::int64(val)); + break; + } - case opcodes::SHORT_BINSTRING: - { - uint8_t length = read_byte(); - stack_.push_back(value::string(read_string(length))); - break; - } + case opcodes::BINFLOAT: { + const uint64_t bits = std::byteswap(read_uint64_le()); + double val; + std::memcpy(&val, &bits, sizeof(double)); + stack_.push_back(value::float64(val)); + break; + } - case opcodes::BINSTRING: - { - uint32_t length = read_uint32_le(); - stack_.push_back(value::string(read_string(length))); - break; - } + case opcodes::SHORT_BINSTRING: { + uint8_t length = read_byte(); + stack_.push_back(value::string(read_string(length))); + break; + } - case opcodes::SHORT_BINUNICODE: - { - uint8_t length = read_byte(); - stack_.push_back(value::string(read_string(length))); - break; - } + case opcodes::BINSTRING: { + uint32_t length = read_uint32_le(); + stack_.push_back(value::string(read_string(length))); + break; + } - case opcodes::BINUNICODE: - { - uint32_t length = read_uint32_le(); - stack_.push_back(value::string(read_string(length))); - break; - } + case opcodes::SHORT_BINUNICODE: { + uint8_t length = read_byte(); + stack_.push_back(value::string(read_string(length))); + break; + } - case opcodes::SHORT_BINBYTES: - { - uint8_t length = read_byte(); - stack_.push_back(value::bytes(read_string(length))); - break; - } + case opcodes::BINUNICODE: { + uint32_t length = read_uint32_le(); + stack_.push_back(value::string(read_string(length))); + break; + } - case opcodes::BINBYTES: - { - uint32_t length = read_uint32_le(); - stack_.push_back(value::bytes(read_string(length))); - break; - } + case opcodes::SHORT_BINBYTES: { + uint8_t length = read_byte(); + stack_.push_back(value::bytes(read_string(length))); + break; + } - case opcodes::EMPTY_LIST: - stack_.push_back(value::list({})); - break; + case opcodes::BINBYTES: { + uint32_t length = read_uint32_le(); + stack_.push_back(value::bytes(read_string(length))); + break; + } - case opcodes::APPEND: - { - if (stack_.size() < 2) { - throw std::runtime_error("Not enough items on stack for APPEND"); - } - auto item = std::move(stack_.back()); - stack_.pop_back(); - auto &list_val = stack_.back(); - if (list_val->get_type() != value::type::list) { - throw std::runtime_error("APPEND target is not a list"); - } - auto &list_data = const_cast(list_val->as_list()); - list_data.push_back(std::move(item)); - break; - } + case opcodes::EMPTY_LIST: + stack_.push_back(value::list({})); + break; - case opcodes::APPENDS: - { - auto items = pop_to_mark(); - if (stack_.empty()) { - throw std::runtime_error("No list on stack for APPENDS"); - } - auto &list_val = stack_.back(); - if (list_val->get_type() != value::type::list) { - throw std::runtime_error("APPENDS target is not a list"); - } - auto &list_data = const_cast(list_val->as_list()); - for (auto &item: items) { - list_data.push_back(std::move(item)); - } - break; + case opcodes::APPEND: { + if (stack_.size() < 2) { + throw std::runtime_error("Not enough items on stack for APPEND"); + } + auto item = std::move(stack_.back()); + stack_.pop_back(); + const auto &list_val = stack_.back(); + if (list_val->get_type() != value::type::list) { + throw std::runtime_error("APPEND target is not a list"); } + auto &list_data = const_cast(list_val->as_list()); + list_data.push_back(std::move(item)); + break; + } - case opcodes::LIST: - { - auto items = pop_to_mark(); - stack_.push_back(value::list(std::move(items))); - break; + case opcodes::APPENDS: { + auto items = pop_to_mark(); + if (stack_.empty()) { + throw std::runtime_error("No list on stack for APPENDS"); } + const auto &list_val = stack_.back(); + if (list_val->get_type() != value::type::list) { + throw std::runtime_error("APPENDS target is not a list"); + } + auto &list_data = const_cast(list_val->as_list()); + std::ranges::move(items, std::back_inserter(list_data)); + break; + } - case opcodes::EMPTY_TUPLE: - stack_.push_back(value::tuple({})); - break; + case opcodes::LIST: { + auto items = pop_to_mark(); + stack_.push_back(value::list(std::move(items))); + break; + } - case opcodes::TUPLE: - { - auto items = pop_to_mark(); - stack_.push_back(value::tuple(std::move(items))); - break; - } + case opcodes::EMPTY_TUPLE: + stack_.push_back(value::tuple({})); + break; - case opcodes::TUPLE1: - { - if (stack_.empty()) { - throw std::runtime_error("Not enough items on stack for TUPLE1"); - } - auto item = std::move(stack_.back()); - stack_.pop_back(); - list tuple_items; - tuple_items.push_back(std::move(item)); - stack_.push_back(value::tuple(std::move(tuple_items))); - break; + case opcodes::TUPLE: { + auto items = pop_to_mark(); + stack_.push_back(value::tuple(std::move(items))); + break; + } + + case opcodes::TUPLE1: { + if (stack_.empty()) { + throw std::runtime_error("Not enough items on stack for TUPLE1"); } + auto item = std::move(stack_.back()); + stack_.pop_back(); + list tuple_items; + tuple_items.push_back(std::move(item)); + stack_.push_back(value::tuple(std::move(tuple_items))); + break; + } - case opcodes::TUPLE2: - { - if (stack_.size() < 2) { - throw std::runtime_error("Not enough items on stack for TUPLE2"); - } - auto item2 = std::move(stack_.back()); - stack_.pop_back(); - auto item1 = std::move(stack_.back()); - stack_.pop_back(); - list tuple_items; - tuple_items.push_back(std::move(item1)); - tuple_items.push_back(std::move(item2)); - stack_.push_back(value::tuple(std::move(tuple_items))); - break; + case opcodes::TUPLE2: { + if (stack_.size() < 2) { + throw std::runtime_error("Not enough items on stack for TUPLE2"); } + auto item2 = std::move(stack_.back()); + stack_.pop_back(); + auto item1 = std::move(stack_.back()); + stack_.pop_back(); + list tuple_items; + tuple_items.push_back(std::move(item1)); + tuple_items.push_back(std::move(item2)); + stack_.push_back(value::tuple(std::move(tuple_items))); + break; + } - case opcodes::TUPLE3: - { - if (stack_.size() < 3) { - throw std::runtime_error("Not enough items on stack for TUPLE3"); - } - auto item3 = std::move(stack_.back()); - stack_.pop_back(); - auto item2 = std::move(stack_.back()); - stack_.pop_back(); - auto item1 = std::move(stack_.back()); - stack_.pop_back(); - list tuple_items; - tuple_items.push_back(std::move(item1)); - tuple_items.push_back(std::move(item2)); - tuple_items.push_back(std::move(item3)); - stack_.push_back(value::tuple(std::move(tuple_items))); - break; + case opcodes::TUPLE3: { + if (stack_.size() < 3) { + throw std::runtime_error("Not enough items on stack for TUPLE3"); } + auto item3 = std::move(stack_.back()); + stack_.pop_back(); + auto item2 = std::move(stack_.back()); + stack_.pop_back(); + auto item1 = std::move(stack_.back()); + stack_.pop_back(); + list tuple_items; + tuple_items.push_back(std::move(item1)); + tuple_items.push_back(std::move(item2)); + tuple_items.push_back(std::move(item3)); + stack_.push_back(value::tuple(std::move(tuple_items))); + break; + } - case opcodes::EMPTY_DICT: - stack_.push_back(value::dict({})); - break; + case opcodes::EMPTY_DICT: + stack_.push_back(value::dict({})); + break; - case opcodes::DICT: - { - auto items = pop_to_mark(); - if (items.size() % 2 != 0) { - throw std::runtime_error("Odd number of items for DICT"); - } - dict dict_data; - for (size_t i = 0; i < items.size(); i += 2) { - if (items[i]->get_type() != value::type::string) { - throw std::runtime_error("Dict key must be string"); - } - std::string key = items[i]->as_string(); - dict_data[std::move(key)] = std::move(items[i + 1]); - } - stack_.push_back(value::dict(std::move(dict_data))); - break; + case opcodes::DICT: { + auto items = pop_to_mark(); + if (items.size() % 2 != 0) { + throw std::runtime_error("Odd number of items for DICT"); } - - case opcodes::SETITEM: - { - if (stack_.size() < 3) { - throw std::runtime_error("Not enough items on stack for SETITEM"); - } - auto val = std::move(stack_.back()); - stack_.pop_back(); - auto key = std::move(stack_.back()); - stack_.pop_back(); - auto &dict_val = stack_.back(); - - if (dict_val->get_type() != value::type::dict) { - throw std::runtime_error("SETITEM target is not a dict"); - } - if (key->get_type() != value::type::string) { + dict dict_data; + for (size_t i = 0; i < items.size(); i += 2) { + if (items[i]->get_type() != value::type::string) { throw std::runtime_error("Dict key must be string"); } + std::string key = items[i]->as_string(); + dict_data[std::move(key)] = std::move(items[i + 1]); + } + stack_.push_back(value::dict(std::move(dict_data))); + break; + } - auto &dict_data = const_cast(dict_val->as_dict()); - dict_data[key->as_string()] = std::move(val); - break; + case opcodes::SETITEM: { + if (stack_.size() < 3) { + throw std::runtime_error("Not enough items on stack for SETITEM"); + } + auto val = std::move(stack_.back()); + stack_.pop_back(); + auto key = std::move(stack_.back()); + stack_.pop_back(); + const auto &dict_val = stack_.back(); + + if (dict_val->get_type() != value::type::dict) { + throw std::runtime_error("SETITEM target is not a dict"); + } + if (key->get_type() != value::type::string) { + throw std::runtime_error("Dict key must be string"); } - case opcodes::SETITEMS: - { - auto items = pop_to_mark(); - if (items.size() % 2 != 0) { - throw std::runtime_error("Odd number of items for SETITEMS"); - } - if (stack_.empty()) { - throw std::runtime_error("No dict on stack for SETITEMS"); - } - auto &dict_val = stack_.back(); - if (dict_val->get_type() != value::type::dict) { - throw std::runtime_error("SETITEMS target is not a dict"); - } + auto &dict_data = const_cast(dict_val->as_dict()); + dict_data[key->as_string()] = std::move(val); + break; + } - auto &dict_data = const_cast(dict_val->as_dict()); - for (size_t i = 0; i < items.size(); i += 2) { - if (items[i]->get_type() != value::type::string) { - throw std::runtime_error("Dict key must be string"); - } - std::string key = items[i]->as_string(); - dict_data[std::move(key)] = std::move(items[i + 1]); - } - break; + case opcodes::SETITEMS: { + auto items = pop_to_mark(); + if (items.size() % 2 != 0) { + throw std::runtime_error("Odd number of items for SETITEMS"); } - - case opcodes::BINPUT: - { - uint8_t memo_id = read_byte(); - if (stack_.empty()) { - throw std::runtime_error("No item on stack for BINPUT"); - } - // For simplicity, we create a copy for memo storage - // In a full implementation, you'd want to share the object - memo_[memo_id] = nullptr; // Placeholder for now - break; + if (stack_.empty()) { + throw std::runtime_error("No dict on stack for SETITEMS"); + } + const auto &dict_val = stack_.back(); + if (dict_val->get_type() != value::type::dict) { + throw std::runtime_error("SETITEMS target is not a dict"); } - case opcodes::LONG_BINPUT: - { - uint32_t memo_id = read_uint32_le(); - if (stack_.empty()) { - throw std::runtime_error("No item on stack for LONG_BINPUT"); + auto &dict_data = const_cast(dict_val->as_dict()); + for (size_t i = 0; i < items.size(); i += 2) { + if (items[i]->get_type() != value::type::string) { + throw std::runtime_error("Dict key must be string"); } - // For simplicity, we create a copy for memo storage - // In a full implementation, you'd want to share the object - memo_[memo_id] = nullptr; // Placeholder for now - break; + std::string key = items[i]->as_string(); + dict_data[std::move(key)] = std::move(items[i + 1]); } + break; + } - case opcodes::BINGET: - { - uint8_t memo_id = read_byte(); - if (auto it = memo_.find(memo_id); it == memo_.end()) { - throw std::runtime_error("Memo key not found"); - } - // For now, just push a placeholder - stack_.push_back(value::none()); - break; + case opcodes::BINPUT: { + uint8_t memo_id = read_byte(); + if (stack_.empty()) { + throw std::runtime_error("No item on stack for BINPUT"); } + memo_[memo_id] = clone(*stack_.back()); + break; + } - case opcodes::LONG_BINGET: - { - uint32_t memo_id = read_uint32_le(); - if (auto it = memo_.find(memo_id); it == memo_.end()) { - throw std::runtime_error("Memo key not found"); - } - // For now, just push a placeholder - stack_.push_back(value::none()); - break; + case opcodes::LONG_BINPUT: { + uint32_t memo_id = read_uint32_le(); + if (stack_.empty()) { + throw std::runtime_error("No item on stack for LONG_BINPUT"); } + memo_[memo_id] = clone(*stack_.back()); + break; + } - case opcodes::PROTO: - { - uint8_t proto = read_byte(); - // Just ignore protocol version for now - break; + case opcodes::BINGET: { + uint8_t memo_id = read_byte(); + const auto it = memo_.find(memo_id); + if (it == memo_.end()) { + throw std::runtime_error("Memo key not found"); } + stack_.push_back(clone(*it->second)); + break; + } - case opcodes::FRAME: - { - uint64_t frame_size = read_uint64_le(); - // Just ignore frame size for now - break; + case opcodes::LONG_BINGET: { + uint32_t memo_id = read_uint32_le(); + const auto it = memo_.find(memo_id); + if (it == memo_.end()) { + throw std::runtime_error("Memo key not found"); } + stack_.push_back(clone(*it->second)); + break; + } - case opcodes::LONG1: - { - uint8_t length = read_byte(); - if (length == 0) { - stack_.push_back(value::int64(0)); - } else { - std::string bytes_data = read_string(length); - int64_t result = 0; - - // Convert little-endian bytes to integer - for (int i = length - 1; i >= 0; --i) { - result = result << 8 | static_cast(bytes_data[i]); - } - - // Handle two's complement for negative numbers - if (length > 0 && static_cast(bytes_data[length - 1]) & 0x80) { - // Extend sign bit - for (int i = length; i < 8; ++i) { - result |= 0xFFLL << i * 8; - } - } - - stack_.push_back(value::int64(result)); + case opcodes::PROTO: { + read_byte(); + // Just ignore protocol version for now + break; + } + + case opcodes::FRAME: { + read_uint64_le(); + // Just ignore frame size for now + break; + } + + case opcodes::LONG1: { + uint8_t length = read_byte(); + if (length == 0) { + stack_.push_back(value::int64(0)); + } else { + if (length > sizeof(std::int64_t)) { + throw std::runtime_error("LONG1 value does not fit in int64"); } - break; - } - case opcodes::MEMOIZE: - { - if (stack_.empty()) { - throw std::runtime_error("No item on stack for MEMOIZE"); + const std::string bytes_data = read_string(length); + auto bits = std::accumulate(bytes_data.rbegin(), bytes_data.rend(), std::uint64_t{0}, + [](const std::uint64_t current, const char byte) { + return current << 8 | static_cast(byte); + }); + + // Handle two's complement for negative numbers + if (length < sizeof(bits) && (static_cast(bytes_data.back()) & 0x80) != 0) { + bits |= ~std::uint64_t{0} << length * 8; } - // Store the top item in memo with auto-incrementing ID - auto memo_id = static_cast(memo_.size()); - memo_[memo_id] = nullptr; // Placeholder for now - break; + + stack_.push_back(value::int64(std::bit_cast(bits))); } + break; + } + + case opcodes::MEMOIZE: { + if (stack_.empty()) { + throw std::runtime_error("No item on stack for MEMOIZE"); + } + const auto memo_id = static_cast(memo_.size()); + memo_[memo_id] = clone(*stack_.back()); + break; + } - default: - throw std::runtime_error("Unsupported pickle opcode: " + std::to_string(opcode)); + default: + throw std::runtime_error("Unsupported pickle opcode: " + std::to_string(opcode)); } return nullptr; // Continue parsing @@ -570,4 +562,4 @@ namespace pickle const auto byte_span = std::span(reinterpret_cast(data.data()), data.size()); return loads(byte_span); } -} +} // namespace pickle diff --git a/src/modules/renpy/pickle.h b/src/modules/renpy/pickle.h index eacb388..cfae788 100644 --- a/src/modules/renpy/pickle.h +++ b/src/modules/renpy/pickle.h @@ -1,12 +1,13 @@ #pragma once -#include -#include -#include -#include +#include #include #include #include +#include +#include +#include +#include namespace pickle { @@ -18,8 +19,8 @@ namespace pickle class value { - public: - enum class type + public: + enum class type : std::uint8_t { none, bool_, @@ -32,19 +33,19 @@ namespace pickle tuple }; - private: + private: type type_; - std::variant< - std::monostate, // none - bool, // bool_ - int64_t, // int64 - double, // float64 - std::string, // bytes/string - list, // list/tuple - dict // dict - > data_; - - public: + std::variant + data_; + + public: explicit value(const type t) : type_(t) { } @@ -110,7 +111,10 @@ namespace pickle return v; } - type get_type() const { return type_; } + type get_type() const + { + return type_; + } bool as_bool() const { @@ -169,9 +173,11 @@ namespace pickle } }; + value_ptr clone(const value &source); + class parser { - private: + private: std::span data_; size_t pos_ = 0; std::vector stack_; @@ -196,7 +202,7 @@ namespace pickle value_ptr parse_value(); - public: + public: explicit parser(const std::span data) : data_(data) { } @@ -207,4 +213,4 @@ namespace pickle value_ptr loads(std::span data); value_ptr loads(const std::string &data); -} +} // namespace pickle diff --git a/src/modules/renpy/renpy.cpp b/src/modules/renpy/renpy.cpp index 63b047b..9692565 100644 --- a/src/modules/renpy/renpy.cpp +++ b/src/modules/renpy/renpy.cpp @@ -1,10 +1,11 @@ +#include "../../core/compression/zlib_codec.h" +#include "../../core/io/bounded_stream.h" #include "../extractor.h" #include "pickle.h" #include #include - -#include +#include namespace extractor { @@ -12,11 +13,12 @@ namespace extractor { return { {0x9486718f, 0x8f0a, 0x4de7, {0x98, 0x80, 0x01, 0x14, 0x6b, 0x33, 0x6d, 0x6b}}, - 3, 0, + 3, + 0, }; } - std::vector extractor::get_signature() noexcept + std::vector extractor::get_signature() { const std::string str = "RPA-"; std::vector signature(str.size()); @@ -24,18 +26,20 @@ namespace extractor return signature; } - // ReSharper disable once CppMemberFunctionMayBeStatic - archive_info extractor::get_archive_info(const std::span &data) noexcept // NOLINT(*-convert-member-functions-to-static) + archive_info extractor::get_archive_info( + const std::span &data) // NOLINT(*-convert-member-functions-to-static) { + static_cast(data); return archive_info{L"RenPy", L"", L""}; } - int64_t read_int64(std::ifstream &stream) + int64_t read_int64(observer::io::bounded_stream &stream) { std::string buffer(sizeof(int64_t) * 2, '\0'); - stream.read(buffer.data(), std::ssize(buffer)); + stream.read_exact(buffer.data(), buffer.size()); char *end_ptr = nullptr; + errno = 0; const int64_t result = std::strtoll(buffer.c_str(), &end_ptr, 16); if (errno == ERANGE || result == 0 || result < 0) { throw std::out_of_range("NumberReadNotANumberError"); @@ -44,68 +48,68 @@ namespace extractor return result; } - std::pair(int64_t, int64_t)> > parse_header( - std::ifstream &stream) + std::pair(int64_t, int64_t)>> parse_header( + observer::io::bounded_stream &stream) { std::string version_check(3, '\0'); - stream.seekg(static_cast(extractor::get_signature().size())); - stream.read(version_check.data(), 3); + stream.seek_absolute(static_cast(extractor::get_signature().size())); + stream.read_exact(version_check.data(), version_check.size()); if (version_check == "2.0") { - stream.seekg(static_cast(std::string("RPA-2.0 ").length())); + stream.seek_absolute(static_cast(std::string("RPA-2.0 ").length())); const auto index_offset = read_int64(stream); - return { - index_offset, [](int64_t offset, int64_t length) - { - return std::make_pair(offset, length); - } - }; + return {index_offset, [](int64_t offset, int64_t length) { return std::make_pair(offset, length); }}; } if (version_check == "3.0") { - stream.seekg(static_cast(std::string("RPA-3.0 ").length())); + stream.seek_absolute(static_cast(std::string("RPA-3.0 ").length())); const auto index_offset = read_int64(stream); const auto encryption_key = read_int64(stream); - return { - index_offset, [encryption_key](int64_t offset, int64_t length) - { - return std::make_pair(offset ^ encryption_key, length ^ encryption_key); - } - }; + return {index_offset, [encryption_key](int64_t offset, int64_t length) { + return std::make_pair(offset ^ encryption_key, length ^ encryption_key); + }}; } throw std::runtime_error("Unsupported RPA version"); } - // ReSharper disable once CppMemberFunctionMayBeStatic - std::vector > extractor::list_files(std::ifstream &stream) // NOLINT(*-convert-member-functions-to-static) + std::vector> extractor::list_files( + std::istream &stream) // NOLINT(*-convert-member-functions-to-static) { - const auto [index_offset, decoder] = parse_header(stream); - - stream.seekg(index_offset); - - std::string decompressed_data; - try { - zstr::istream zs(stream); - decompressed_data.assign(std::istreambuf_iterator(zs), std::istreambuf_iterator()); - } catch (const std::ios_base::failure &) { + observer::io::bounded_stream input(stream); + const auto [index_offset, decoder] = parse_header(input); + const auto archive_size = input.size(); + if (index_offset >= archive_size) { throw read_error(); } + input.seek_absolute(index_offset); + + constexpr std::size_t max_compressed_index_size = 64ULL * 1024 * 1024; + constexpr std::size_t max_decompressed_index_size = 64ULL * 1024 * 1024; + const auto compressed_size = archive_size - index_offset; + if (static_cast(compressed_size) > max_compressed_index_size) { + throw std::runtime_error("RPA compressed index exceeds the metadata budget"); + } + + std::vector compressed_data(static_cast(compressed_size)); + input.read_exact(reinterpret_cast(compressed_data.data()), compressed_data.size()); + const auto decompressed_data = + observer::compression::decompress_zlib(compressed_data, max_decompressed_index_size); auto root = pickle::loads(decompressed_data); const auto &dict = root->as_dict(); - auto files = std::vector >(); + auto files = std::vector>(); files.reserve(dict.size()); - for (const auto &[file_name, value]: dict) { + for (const auto &[file_name, value] : dict) { const auto &props_container = value->as_list(); if (props_container.size() != 1) { - throw std::logic_error("Not implemented"); + throw std::runtime_error("Expected exactly one property tuple"); } const auto &props = props_container[0]->as_tuple(); if (props.size() < 2) { - throw std::logic_error("Expected at least 2 elements in tuple"); + throw std::runtime_error("Expected at least 2 elements in tuple"); } const auto [offset, body_size] = decoder(props[0]->as_int64(), props[1]->as_int64()); @@ -130,6 +134,7 @@ namespace extractor uint32_t extractor::decrypt(uint32_t magic, std::vector &data) const { + static_cast(data); return magic; } -} +} // namespace extractor diff --git a/src/modules/rpgmaker/rpgmaker.cpp b/src/modules/rpgmaker/rpgmaker.cpp index af6baf0..6a0fa04 100644 --- a/src/modules/rpgmaker/rpgmaker.cpp +++ b/src/modules/rpgmaker/rpgmaker.cpp @@ -1,3 +1,5 @@ +#include "../../core/archive_limits.h" +#include "../../core/io/bounded_stream.h" #include "../extractor.h" #include @@ -8,11 +10,12 @@ namespace extractor { return { {0xc4674077, 0x464a, 0x425b, {0x89, 0x80, 0x9e, 0x14, 0xe8, 0x16, 0x49, 0x00}}, - 1, 0, + 1, + 0, }; } - std::vector extractor::get_signature() noexcept + std::vector extractor::get_signature() { const std::string str = "RGSSAD"; std::vector signature(str.size()); @@ -22,40 +25,42 @@ namespace extractor return signature; } - // ReSharper disable once CppMemberFunctionMayBeStatic - archive_info extractor::get_archive_info(const std::span &data) noexcept // NOLINT(*-convert-member-functions-to-static) + archive_info extractor::get_archive_info( + const std::span &data) // NOLINT(*-convert-member-functions-to-static) { + static_cast(data); return archive_info{L"RGSS3", L"-", L"RPG Maker VX Ace"}; } - static uint32_t read_u32(std::ifstream &stream) + std::vector> extractor::list_files( + std::istream &stream) // NOLINT(*-convert-member-functions-to-static) { - uint32_t value; - stream.read(reinterpret_cast(&value), sizeof(value)); - return value; - } - - // ReSharper disable once CppMemberFunctionMayBeStatic - std::vector > extractor::list_files(std::ifstream &stream) // NOLINT(*-convert-member-functions-to-static) - { - stream.seekg(static_cast(get_signature().size())); + observer::io::bounded_stream input(stream); + input.seek_absolute(static_cast(get_signature().size())); + const auto archive_size = input.size(); - std::vector > files; - const uint32_t magic = read_u32(stream) * 9 + 3; + std::vector> files; + const uint32_t magic = input.read_trivial() * 9 + 3; while (true) { - const uint32_t offset = read_u32(stream) ^ magic; - if (offset == 0) break; + const uint32_t offset = input.read_trivial() ^ magic; + if (offset == 0) + break; - const uint32_t size = read_u32(stream) ^ magic; - const uint32_t file_magic = read_u32(stream) ^ magic; - const uint32_t name_len = read_u32(stream) ^ magic; + const uint32_t size = input.read_trivial() ^ magic; + const uint32_t file_magic = input.read_trivial() ^ magic; + const uint32_t name_len = input.read_trivial() ^ magic; + + const auto name_position = input.position(); + if (name_len > observer::archive_limits::max_path_bytes || + static_cast(name_len) > static_cast(archive_size - name_position)) { + throw read_error(); + } std::vector name_buf(name_len); - stream.read(name_buf.data(), name_len); + input.read_exact(name_buf.data(), name_buf.size()); for (size_t i = 0; i < name_len; ++i) { - name_buf[i] = static_cast( - static_cast(name_buf[i]) ^ - static_cast(magic >> (8 * (i % 4)))); + name_buf[i] = static_cast(static_cast(name_buf[i]) ^ + static_cast(magic >> (8 * (i % 4)))); } auto new_file = std::make_unique(); @@ -76,8 +81,8 @@ namespace extractor return old; } - // ReSharper disable once CppMemberFunctionMayBeStatic - uint32_t extractor::decrypt(uint32_t magic, std::vector &data) const // NOLINT(*-convert-member-functions-to-static) + uint32_t extractor::decrypt(uint32_t magic, + std::vector &data) const // NOLINT(*-convert-member-functions-to-static) { const size_t size = data.size(); size_t i = 0; @@ -98,4 +103,4 @@ namespace extractor return magic; } -} +} // namespace extractor diff --git a/src/modules/zanzarah/zanzarah.cpp b/src/modules/zanzarah/zanzarah.cpp index e4cb88f..d96a130 100644 --- a/src/modules/zanzarah/zanzarah.cpp +++ b/src/modules/zanzarah/zanzarah.cpp @@ -1,3 +1,5 @@ +#include "../../core/archive_limits.h" +#include "../../core/io/bounded_stream.h" #include "../extractor.h" #include @@ -9,30 +11,26 @@ namespace extractor { return { {0x86e7e4c3, 0xbc44, 0x4e8e, {0x90, 0xaf, 0xbd, 0xbd, 0x1c, 0xb6, 0x1a, 0x83}}, - 2, 0, + 2, + 0, }; } - std::vector extractor::get_signature() noexcept + std::vector extractor::get_signature() { return {std::byte{0}, std::byte{0}, std::byte{0}, std::byte{0}}; } - // ReSharper disable once CppMemberFunctionMayBeStatic - archive_info extractor::get_archive_info(const std::span &data) noexcept // NOLINT(*-convert-member-functions-to-static) + archive_info extractor::get_archive_info( + const std::span &data) // NOLINT(*-convert-member-functions-to-static) { + static_cast(data); return archive_info{L"Zanzarah", L"", L""}; } - int32_t read_positive_or_zero_int32(std::ifstream &stream) + int32_t read_positive_or_zero_int32(observer::io::bounded_stream &stream) { - int32_t value; - - try { - stream.read(reinterpret_cast(&value), sizeof(value)); - } catch (std::ios_base::failure &) { - throw read_error(); - } + const auto value = stream.read_trivial(); if (value < 0) { throw read_error(); @@ -41,7 +39,7 @@ namespace extractor return value; } - int32_t read_positive_int32(std::ifstream &stream) + int32_t read_positive_int32(observer::io::bounded_stream &stream) { const int32_t value = read_positive_or_zero_int32(stream); if (value == 0) { @@ -50,36 +48,57 @@ namespace extractor return value; } - // ReSharper disable once CppMemberFunctionMayBeStatic - std::vector > extractor::list_files(std::ifstream &stream) // NOLINT(*-convert-member-functions-to-static) + std::vector> extractor::list_files( + std::istream &stream) // NOLINT(*-convert-member-functions-to-static) { - stream.seekg(static_cast(get_signature().size())); + observer::io::bounded_stream input(stream); + input.seek_absolute(static_cast(get_signature().size())); + const auto archive_size = input.size(); + const auto file_count = static_cast(read_positive_int32(input)); + constexpr std::size_t minimum_entry_metadata_bytes = sizeof(std::int32_t) * 3 + 1; + const auto remaining_bytes = static_cast(input.remaining()); + if (file_count > observer::archive_limits::max_entry_count || + file_count > remaining_bytes / minimum_entry_metadata_bytes) { + throw read_error(); + } - auto files = std::vector >(); - files.reserve(read_positive_int32(stream)); + auto files = std::vector>(); + files.reserve(file_count); std::string path; - for (size_t i = 0; i < files.capacity(); ++i) { - const auto path_len = read_positive_int32(stream); + for (std::size_t i = 0; i < file_count; ++i) { + const auto path_len = static_cast(read_positive_int32(input)); + if (path_len > observer::archive_limits::max_path_bytes || + path_len > static_cast(input.remaining())) { + throw read_error(); + } path.resize(path_len); - stream.read(path.data(), path_len); + input.read_exact(path.data(), path.size()); - const auto block_offset = read_positive_or_zero_int32(stream); - const auto block_size = read_positive_int32(stream); + const auto block_offset = read_positive_or_zero_int32(input); + const auto block_size = read_positive_int32(input); constexpr int32_t attr_size = 4; + if (block_size < attr_size) { + throw read_error(); + } auto new_file = std::make_unique(); new_file->path = path; - new_file->offset = block_offset + attr_size; - new_file->compressed_body_size_in_bytes = block_size - attr_size; + new_file->offset = static_cast(block_offset) + attr_size; + new_file->compressed_body_size_in_bytes = static_cast(block_size) - attr_size; new_file->uncompressed_body_size_in_bytes = new_file->compressed_body_size_in_bytes; files.push_back(std::move(new_file)); } - for (const auto &file: files) { - file->offset += stream.tellg(); + const auto body_position = input.position(); + const auto body_bytes = static_cast(archive_size - body_position); + for (const auto &file : files) { + if (file->offset > body_bytes || file->compressed_body_size_in_bytes > body_bytes - file->offset) { + throw read_error(); + } + file->offset += body_position; if (constexpr std::string_view relative_prefix = "..\\"; file->path.starts_with(relative_prefix)) { file->path.erase(0, relative_prefix.size()); } @@ -90,6 +109,7 @@ namespace extractor uint32_t extractor::decrypt(uint32_t magic, std::vector &data) const { + static_cast(data); return magic; } -} +} // namespace extractor diff --git a/src/tests/framework/observer.cpp b/src/tests/framework/observer.cpp index 127930a..5c3abcc 100644 --- a/src/tests/framework/observer.cpp +++ b/src/tests/framework/observer.cpp @@ -1,55 +1,102 @@ #include "observer.h" #include "../../api.h" +#include "../support/archive_fixtures.h" +#include +#include +#include #include +#include +#include -#include #include +#include namespace test { + enum class module_path_policy : std::uint8_t + { + loader_search, + exact, + }; + class c_module final : public module { - public: + public: explicit c_module(const std::string &dll_name) + : c_module(std::filesystem::path(dll_name), module_path_policy::loader_search) { - dll_ = LoadLibrary(dll_name.c_str()); - if (dll_ == nullptr) { - throw std::runtime_error("Failed to load DLL"); - } - - const auto load = reinterpret_cast(GetProcAddress(dll_, "LoadSubModule")); - unload_module_ = reinterpret_cast(GetProcAddress(dll_, "UnloadSubModule")); - - ModuleLoadParameters load_params{}; - load_params.StructSize = sizeof(load_params); - load_params.Settings = nullptr; - load(&load_params); - api_ = load_params.ApiFuncs; - module_loaded_ = true; } - ~c_module() override + c_module(const std::filesystem::path &dll_path, const module_path_policy path_policy) { - if (storage_ != nullptr) { - api_.CloseStorage(storage_); + if (path_policy == module_path_policy::exact && !dll_path.is_absolute()) { + throw std::runtime_error("An exact module path must be absolute"); } - if (module_loaded_) { - unload_module_(); - unload_module_ = nullptr; + const auto load_path = + path_policy == module_path_policy::exact ? std::filesystem::canonical(dll_path) : dll_path; + dll_ = path_policy == module_path_policy::exact + ? LoadLibraryExW(load_path.c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) + : LoadLibraryW(load_path.c_str()); + if (dll_ == nullptr) { + throw std::runtime_error( + std::format("Failed to load {} (Win32 error {})", load_path.string(), GetLastError())); } - if (dll_ != nullptr) { - FreeLibrary(dll_); + try { + if (path_policy == module_path_policy::exact) { + std::wstring actual_path(32'768, L'\0'); + const auto length = + GetModuleFileNameW(dll_, actual_path.data(), static_cast(actual_path.size())); + if (length == 0 || length >= actual_path.size()) { + throw std::runtime_error("Failed to resolve the loaded package module path"); + } + actual_path.resize(length); + if (!std::filesystem::equivalent(load_path, std::filesystem::canonical(actual_path))) { + throw std::runtime_error("The loader did not map the requested canonical package module"); + } + } + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wcast-function-type-mismatch" +#endif + load_module_ = reinterpret_cast(GetProcAddress(dll_, "LoadSubModule")); + unload_module_ = reinterpret_cast(GetProcAddress(dll_, "UnloadSubModule")); +#ifdef __clang__ +#pragma clang diagnostic pop +#endif + if (load_module_ == nullptr || unload_module_ == nullptr) { + throw std::runtime_error("The package module does not expose the Observer entry points"); + } + + ModuleLoadParameters load_params{}; + load_params.StructSize = sizeof(load_params); + load_params.Settings = nullptr; + if (load_module_(&load_params) == FALSE) { + throw std::runtime_error("LoadSubModule rejected its valid package-smoke parameters"); + } + api_ = load_params.ApiFuncs; + module_loaded_ = true; + } catch (...) { + release(); + throw; } } + ~c_module() override + { + release(); + } + bool open(const std::filesystem::path &path) { + REQUIRE(storage_ == nullptr); std::ifstream input(path, std::ios::binary); REQUIRE(input.is_open()); - input.exceptions(std::ofstream::failbit | std::ofstream::badbit); + input.exceptions(std::ofstream::badbit); auto len = 128 * 1024; std::string signature; @@ -79,6 +126,7 @@ namespace test { REQUIRE(storage_ != nullptr); REQUIRE(api_.PrepareFiles(storage_)); + REQUIRE(api_.PrepareFiles(storage_)); std::vector files{}; @@ -97,12 +145,12 @@ namespace test REQUIRE(item.NumHardlinks == 0); REQUIRE(wcslen(item.Path) > 0); - auto extract = [this, item_index](const std::filesystem::path &path) - { + auto extract = [this, item_index](const std::filesystem::path &path) { extract_file(item_index, path); }; - files.emplace_back(item.Path, item.Size, item.PackedSize, extract); + files.emplace_back(item.Path, static_cast(item.Size), + static_cast(item.PackedSize), extract); ++item_index; } @@ -110,14 +158,32 @@ namespace test return files; } + [[nodiscard]] const module_cbs &api() const noexcept + { + return api_; + } + + [[nodiscard]] HANDLE storage() const noexcept + { + return storage_; + } + + [[nodiscard]] int load(ModuleLoadParameters *params) const + { + return load_module_(params); + } + void extract_file(const int file_index, const std::filesystem::path &path) const { - constexpr ExtractProcessCallbacks callbacks{ + REQUIRE(extract_file_status(file_index, path, [](void *, int64_t) { return TRUE; }) == SER_SUCCESS); + } + + [[nodiscard]] int extract_file_status(const int file_index, const std::filesystem::path &path, + const ExtractProgressFunc report_progress) const + { + const ExtractProcessCallbacks callbacks{ .signalContext = nullptr, - .FileProgress = [](void *context, int64_t bytes_read) - { - return TRUE; - }, + .FileProgress = report_progress, }; const ExtractOperationParams params{ @@ -128,17 +194,68 @@ namespace test .Callbacks = callbacks, }; - REQUIRE(api_.ExtractItem(storage_, params) == SER_SUCCESS); + return api_.ExtractItem(storage_, params); + } + + void close_storage() noexcept + { + if (storage_ != nullptr) { + api_.CloseStorage(storage_); + storage_ = nullptr; + } } - private: - HMODULE dll_; - bool module_loaded_; - UnloadSubModuleFunc unload_module_; + private: + void release() noexcept + { + close_storage(); + if (module_loaded_ && unload_module_ != nullptr) { + unload_module_(); + module_loaded_ = false; + } + unload_module_ = nullptr; + load_module_ = nullptr; + if (dll_ != nullptr) { + static_cast(FreeLibrary(dll_)); + dll_ = nullptr; + } + } + + HMODULE dll_ = nullptr; + bool module_loaded_ = false; + LoadSubModuleFunc load_module_ = nullptr; + UnloadSubModuleFunc unload_module_ = nullptr; module_cbs api_{}; HANDLE storage_ = nullptr; }; + class temporary_output_file final + { + public: + explicit temporary_output_file(std::filesystem::path path) : path_(std::move(path)) + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + ~temporary_output_file() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + temporary_output_file(const temporary_output_file &) = delete; + temporary_output_file &operator=(const temporary_output_file &) = delete; + + [[nodiscard]] const std::filesystem::path &path() const noexcept + { + return path_; + } + + private: + std::filesystem::path path_; + }; + observer::observer() { modules_.push_back(std::make_unique("renpy.so")); @@ -149,7 +266,7 @@ namespace test std::vector observer::list_files(const std::filesystem::path &path) const { c_module *module = nullptr; - for (const auto &abstract_module: modules_) { + for (const auto &abstract_module : modules_) { const auto candidate = dynamic_cast(abstract_module.get()); REQUIRE(candidate != nullptr); if (candidate->open(path)) { @@ -158,7 +275,353 @@ namespace test } } REQUIRE(module != nullptr); - // ReSharper disable once CppDFANullDereference return module->list_files(); } -} + + TEST_CASE("module ABI: invalid inputs are rejected", "[contract]") + { + constexpr std::array module_names{"renpy.so", "rpgmaker.so", "zanzarah.so"}; + const auto invalid_archive_path = + std::filesystem::temp_directory_path() / + std::format(L"observer-invalid-{}.bin", static_cast(GetCurrentProcessId())); + + for (const auto *module_name : module_names) { + CAPTURE(module_name); + c_module loaded(module_name); + const auto &api = loaded.api(); + + std::string invalid_contents; + if (std::string_view(module_name) == "renpy.so") { + invalid_contents = "RPA-"; + } else if (std::string_view(module_name) == "rpgmaker.so") { + invalid_contents = std::string{"RGSSAD\0\3", 8}; + } else { + invalid_contents.assign(8, '\0'); + } + { + std::ofstream invalid_archive(invalid_archive_path, std::ios::binary | std::ios::trunc); + REQUIRE(invalid_archive.is_open()); + invalid_archive.write(invalid_contents.data(), static_cast(invalid_contents.size())); + } + + StorageGeneralInfo info{}; + HANDLE storage = nullptr; + const std::array signature{std::byte{0xde}, std::byte{0xad}, std::byte{0xbe}, std::byte{0xef}}; + StorageOpenParams params{ + .StructSize = sizeof(StorageOpenParams), + .FilePath = invalid_archive_path.c_str(), + .Password = nullptr, + .Data = signature.data(), + .DataSize = signature.size(), + }; + + REQUIRE(api.OpenStorage(params, nullptr, &info) == SOR_INVALID_FILE); + REQUIRE(api.OpenStorage(params, &storage, nullptr) == SOR_INVALID_FILE); + params.FilePath = nullptr; + REQUIRE(api.OpenStorage(params, &storage, &info) == SOR_INVALID_FILE); + params.FilePath = invalid_archive_path.c_str(); + REQUIRE(loaded.load(nullptr) == FALSE); + + storage = INVALID_HANDLE_VALUE; + REQUIRE(api.OpenStorage(params, &storage, &info) == SOR_INVALID_FILE); + REQUIRE(storage == nullptr); + + params.DataSize = 0; + params.FilePath = L"this-file-does-not-exist.observer-test"; + REQUIRE(api.OpenStorage(params, &storage, &info) == SOR_INVALID_FILE); + REQUIRE(storage == nullptr); + + REQUIRE(api.PrepareFiles(nullptr) == FALSE); + REQUIRE(api.GetItem(nullptr, 0, nullptr) == GET_ITEM_ERROR); + REQUIRE(api.ExtractItem(nullptr, {}) == SER_ERROR_SYSTEM); + api.CloseStorage(nullptr); + + params.Data = nullptr; + params.FilePath = invalid_archive_path.c_str(); + REQUIRE(api.OpenStorage(params, &storage, &info) == SOR_SUCCESS); + REQUIRE(storage != nullptr); + + REQUIRE(api.PrepareFiles(storage) == FALSE); + REQUIRE(api.GetItem(storage, -1, nullptr) == GET_ITEM_ERROR); + REQUIRE(api.GetItem(storage, 0, nullptr) == GET_ITEM_ERROR); + + StorageItemInfo item{}; + REQUIRE(api.GetItem(storage, 0, &item) == GET_ITEM_NOMOREITEMS); + + ExtractOperationParams extract_params{ + .ItemIndex = -1, + .Flags = 0, + .DestPath = invalid_archive_path.c_str(), + .Password = nullptr, + .Callbacks = {}, + }; + REQUIRE(api.ExtractItem(storage, extract_params) == SER_ERROR_SYSTEM); + extract_params.ItemIndex = 0; + extract_params.DestPath = nullptr; + REQUIRE(api.ExtractItem(storage, extract_params) == SER_ERROR_SYSTEM); + extract_params.DestPath = invalid_archive_path.c_str(); + extract_params.Callbacks.FileProgress = [](void *, int64_t) { return TRUE; }; + REQUIRE(api.ExtractItem(storage, extract_params) == SER_ERROR_SYSTEM); + extract_params.Callbacks.FileProgress = nullptr; + REQUIRE(api.ExtractItem(storage, extract_params) == SER_ERROR_SYSTEM); + + api.CloseStorage(storage); + + const auto expect_current_file_prepare_failure = [&] { + StorageOpenParams corrupt_params{ + .StructSize = sizeof(StorageOpenParams), + .FilePath = invalid_archive_path.c_str(), + .Password = nullptr, + .Data = nullptr, + .DataSize = 0, + }; + HANDLE corrupt_storage = nullptr; + REQUIRE(api.OpenStorage(corrupt_params, &corrupt_storage, &info) == SOR_SUCCESS); + REQUIRE(corrupt_storage != nullptr); + REQUIRE(api.PrepareFiles(corrupt_storage) == FALSE); + api.CloseStorage(corrupt_storage); + }; + + const auto expect_prepare_failure = [&](const std::string_view contents) { + { + std::ofstream invalid_archive(invalid_archive_path, std::ios::binary | std::ios::trunc); + REQUIRE(invalid_archive.is_open()); + invalid_archive.write(contents.data(), static_cast(contents.size())); + } + expect_current_file_prepare_failure(); + }; + + if (std::string_view(module_name) == "renpy.so") { + expect_prepare_failure("RPA-2.0 0000000000000000\n"); + expect_prepare_failure("RPA-2.0 -000000000000001\n"); + expect_prepare_failure("RPA-2.0 ffffffffffffffff\n"); + expect_prepare_failure("RPA-2.0 0000000000000100\n"); + expect_prepare_failure("RPA-3.0 0000000000000020 0000000000000001\n"); + + const support::temporary_sparse_renpy_archive sparse_archive; + REQUIRE((GetFileAttributesW(sparse_archive.path().c_str()) & FILE_ATTRIBUTE_SPARSE_FILE) != 0); + REQUIRE(std::filesystem::file_size(sparse_archive.path()) > 64ULL * 1024 * 1024); + StorageOpenParams sparse_params{ + .StructSize = sizeof(StorageOpenParams), + .FilePath = sparse_archive.path().c_str(), + .Password = nullptr, + .Data = nullptr, + .DataSize = 0, + }; + HANDLE sparse_storage = nullptr; + REQUIRE(api.OpenStorage(sparse_params, &sparse_storage, &info) == SOR_SUCCESS); + REQUIRE(sparse_storage != nullptr); + REQUIRE(api.PrepareFiles(sparse_storage) == FALSE); + api.CloseStorage(sparse_storage); + } else if (std::string_view(module_name) == "zanzarah.so") { + expect_prepare_failure(std::string(4, '\0')); + expect_prepare_failure(std::string{"\0\0\0\0\xff\xff\xff\xff", 8}); + } + } + + std::error_code error; + std::filesystem::remove(invalid_archive_path, error); + } + + TEST_CASE("module ABI: malformed RenPy index shapes are rejected", "[contract]") + { + const auto expect_rejected = [](const std::string_view label, const support::byte_buffer &pickle_index) { + const support::temporary_archive archive(label, support::make_renpy_archive_with_index(pickle_index)); + c_module loaded("renpy.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == FALSE); + }; + + expect_rejected("renpy-empty-properties", {'}', 'U', 1, 'x', ']', 's', '.'}); + expect_rejected("renpy-short-tuple", {'}', 'U', 1, 'x', ']', ')', 'a', 's', '.'}); + } + + TEST_CASE("module ABI: RPG Maker rejects declared paths outside its resource bounds", "[contract]") + { + const auto expect_rejected = [](const std::string_view label, const support::byte_buffer &contents) { + const support::temporary_archive archive(label, contents); + c_module loaded("rpgmaker.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == FALSE); + }; + + expect_rejected("rpgmaker-path-budget", {'R', 'G', 'S', 'S', 'A', 'D', 0, 3, 1, 0, 0, 0, 13, 0, + 0, 0, 13, 0, 0, 0, 13, 0, 0, 0, 0xf3, 0xff, 0xff, 0xff}); + expect_rejected("rpgmaker-path-range", {'R', 'G', 'S', 'S', 'A', 'D', 0, 3, 1, 0, 0, 0, 13, 0, + 0, 0, 13, 0, 0, 0, 13, 0, 0, 0, 14, 0, 0, 0}); + } + + TEST_CASE("module ABI: Zanzarah rejects metadata outside its resource and body bounds", "[contract]") + { + const auto expect_rejected = [](const std::string_view label, const support::byte_buffer &contents) { + const support::temporary_archive archive(label, contents); + c_module loaded("zanzarah.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == FALSE); + }; + + expect_rejected("zanzarah-entry-count", {0, 0, 0, 0, 0xff, 0xff, 0xff, 0x7f}); + expect_rejected("zanzarah-entry-range", {0, 0, 0, 0, 1, 0, 0, 0}); + expect_rejected("zanzarah-path-size", + {0, 0, 0, 0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0x7f, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + expect_rejected("zanzarah-path-range", + {0, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'}); + expect_rejected("zanzarah-small-block", {0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 'a', 0, 0, 0, 0, 1, 0, 0, 0}); + expect_rejected("zanzarah-body-range", + {0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 'a', 64, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 'x'}); + expect_rejected("zanzarah-body-size", + {0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 'a', 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0}); + } + + TEST_CASE("module ABI: invalid RenPy body ranges are read errors", "[contract]") + { + const auto expect_read_error = [](const std::string_view label, const support::byte_buffer &pickle_index) { + const support::temporary_archive archive(label, support::make_renpy_archive_with_index(pickle_index)); + c_module loaded("renpy.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == TRUE); + + const auto destination = archive.path().wstring() + L".out"; + REQUIRE(loaded.extract_file_status(0, destination, [](void *, int64_t) { return TRUE; }) == SER_ERROR_READ); + std::error_code error; + std::filesystem::remove(destination, error); + }; + + expect_read_error("renpy-negative-offset", + {'}', 'U', 1, 'x', ']', 'I', '-', '1', '\n', 'K', 1, 0x86, 'a', 's', '.'}); + expect_read_error("renpy-truncated-body", {'}', 'U', 1, 'x', ']', 'K', 0, 'K', 100, 0x86, 'a', 's', '.'}); + } + + TEST_CASE("module ABI: progress cancellation aborts extraction", "[contract]") + { + const std::string payload(std::size_t{256} * 1024, 'x'); + const support::temporary_archive archive("abort", support::make_rpgmaker_archive("abort.txt", payload)); + c_module loaded("rpgmaker.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.list_files().size() == 1); + + const auto destination = archive.path().wstring() + L".out"; + REQUIRE(loaded.extract_file_status(0, destination, [](void *, int64_t) { return FALSE; }) == SER_USERABORT); + REQUIRE(loaded.extract_file_status(0, destination, [](void *, int64_t) -> int { + throw std::runtime_error("callback"); + }) == SER_ERROR_SYSTEM); + REQUIRE(loaded.extract_file_status(0, std::filesystem::temp_directory_path(), + [](void *, int64_t) { return TRUE; }) == SER_ERROR_WRITE); + + const auto pipe_name = std::format(L"\\\\.\\pipe\\observer-write-failure-{}", GetCurrentProcessId()); + const auto pipe = CreateNamedPipeW(pipe_name.c_str(), PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, 1, 0, 1, 0, nullptr); + REQUIRE(pipe != INVALID_HANDLE_VALUE); + std::jthread pipe_server([pipe] { + static_cast(ConnectNamedPipe(pipe, nullptr)); + static_cast(CloseHandle(pipe)); + }); + REQUIRE(loaded.extract_file_status(0, pipe_name, [](void *, int64_t) { return TRUE; }) == SER_ERROR_WRITE); + + std::error_code error; + std::filesystem::remove(destination, error); + } + + TEST_CASE("module ABI: an item path must fit the ABI buffer", "[contract]") + { + StorageItemInfo item{}; + const std::string oversized_path(std::size(item.Path), 'a'); + const support::temporary_archive archive("oversized-path", + support::make_rpgmaker_archive(oversized_path, "payload")); + c_module loaded("rpgmaker.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == TRUE); + + REQUIRE(loaded.api().GetItem(loaded.storage(), 0, &item) == GET_ITEM_ERROR); + } + + TEST_CASE("module ABI: a large valid Zanzarah metadata table is enumerated", "[contract][metadata]") + { + constexpr std::size_t entry_count = 4'096; + const support::temporary_archive archive("zanzarah-large-metadata", + support::make_zanzarah_archive_with_entries(entry_count)); + c_module loaded("zanzarah.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == TRUE); + + for (std::size_t index = 0; index < entry_count; ++index) { + StorageItemInfo item{}; + REQUIRE(loaded.api().GetItem(loaded.storage(), static_cast(index), &item) == GET_ITEM_OK); + REQUIRE(item.Size == 1); + REQUIRE(item.PackedSize == 1); + } + + StorageItemInfo item{}; + REQUIRE(loaded.api().GetItem(loaded.storage(), static_cast(entry_count), &item) == GET_ITEM_NOMOREITEMS); + } + + TEST_CASE("module ABI: RenPy rejects an index that expands beyond its metadata budget", "[contract][metadata]") + { + constexpr std::size_t expanded_size = 64ULL * 1024 * 1024 + 1; + const support::temporary_archive archive("renpy-expanded-metadata", + support::make_renpy_archive_with_expanded_index(expanded_size)); + c_module loaded("renpy.so"); + REQUIRE(loaded.open(archive.path())); + REQUIRE(loaded.api().PrepareFiles(loaded.storage()) == FALSE); + } + + TEST_CASE("package runtime smoke loads an exact unpacked module", "[package-smoke][.]") + { + const auto require_environment = [](const wchar_t *name) { + const auto required_size = GetEnvironmentVariableW(name, nullptr, 0); + if (required_size == 0) { + throw std::runtime_error(std::format("Required package-smoke environment variable is absent: {}", + std::filesystem::path(name).string())); + } + std::wstring value(required_size, L'\0'); + const auto written = GetEnvironmentVariableW(name, value.data(), required_size); + if (written == 0 || written >= required_size) { + throw std::runtime_error("Failed to read a package-smoke environment variable"); + } + value.resize(written); + return value; + }; + + const std::filesystem::path module_path(require_environment(L"OBSERVER_PACKAGE_MODULE")); + const auto format = require_environment(L"OBSERVER_PACKAGE_FORMAT"); + REQUIRE(module_path.is_absolute()); + REQUIRE(std::filesystem::is_regular_file(module_path)); + + support::byte_buffer archive_contents; + std::wstring expected_path; + std::string expected_payload; + if (format == L"renpy") { + expected_path = L"package\\hello.txt"; + expected_payload = "renpy package smoke"; + archive_contents = support::make_renpy_archive("package/hello.txt", expected_payload); + } else if (format == L"rpgmaker") { + expected_path = L"Data\\package.txt"; + expected_payload = "rpgmaker package smoke"; + archive_contents = support::make_rpgmaker_archive("Data\\package.txt", expected_payload); + } else if (format == L"zanzarah") { + expected_path = L"data\\package.txt"; + expected_payload = "zanzarah package smoke"; + archive_contents = support::make_zanzarah_archive("..\\data\\package.txt", expected_payload); + } else { + throw std::runtime_error("OBSERVER_PACKAGE_FORMAT expects renpy, rpgmaker, or zanzarah"); + } + + const support::temporary_archive archive("package-smoke", archive_contents); + c_module loaded(module_path, module_path_policy::exact); + REQUIRE(loaded.open(archive.path())); + const auto files = loaded.list_files(); + REQUIRE(files.size() == 1); + REQUIRE(files.front().path == expected_path); + REQUIRE(files.front().uncompressed_size == static_cast(expected_payload.size())); + REQUIRE(files.front().compressed_size == static_cast(expected_payload.size())); + + const temporary_output_file destination(archive.path().wstring() + L".out"); + REQUIRE(loaded.extract_file_status(0, destination.path(), [](void *, int64_t) { return TRUE; }) == SER_SUCCESS); + loaded.close_storage(); + REQUIRE(loaded.storage() == nullptr); + std::ifstream extracted(destination.path(), std::ios::binary); + REQUIRE(extracted.is_open()); + const std::string actual_payload{std::istreambuf_iterator(extracted), std::istreambuf_iterator()}; + REQUIRE(actual_payload == expected_payload); + } +} // namespace test diff --git a/src/tests/framework/observer.h b/src/tests/framework/observer.h index dddb506..74bbcbf 100644 --- a/src/tests/framework/observer.h +++ b/src/tests/framework/observer.h @@ -7,30 +7,28 @@ namespace test { struct file { - const std::wstring path; - const int64_t uncompressed_size; - const int64_t compressed_size; + std::wstring path; + const int64_t uncompressed_size = 0; + const int64_t compressed_size = 0; const std::function extract; }; class module { - public: - module() - { - }; + public: + module() {}; virtual ~module() = default; }; class observer final { - public: + public: observer(); std::vector list_files(const std::filesystem::path &path) const; - private: - std::vector > modules_; + private: + std::vector> modules_; }; -} +} // namespace test diff --git a/src/tests/framework/testcase.cpp b/src/tests/framework/testcase.cpp index 9684615..7fb1775 100644 --- a/src/tests/framework/testcase.cpp +++ b/src/tests/framework/testcase.cpp @@ -1,3 +1,5 @@ +#include "testcase.h" + #include "observer.h" #include @@ -9,6 +11,28 @@ namespace test { + class temporary_file final + { + public: + explicit temporary_file(std::filesystem::path path) : path_(std::move(path)) + { + } + + ~temporary_file() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + [[nodiscard]] const std::filesystem::path &path() const noexcept + { + return path_; + } + + private: + std::filesystem::path path_; + }; + std::string wide_to_utf8(const std::wstring &wide) { const auto wide_size = static_cast(wide.size()); @@ -39,7 +63,7 @@ namespace test REQUIRE(file.is_open()); std::string buffer; - buffer.resize(128 * 1024); + buffer.resize(128ULL * 1024); auto state = XXH3_createState(); REQUIRE(state != nullptr); @@ -55,37 +79,70 @@ namespace test return hash_to_string(hash); } - void test_on(const std::filesystem::path &path) + std::string read_file(const std::filesystem::path &path) + { + std::ifstream input(path, std::ios::binary); + REQUIRE(input.is_open()); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; + } + + void test_archive(const std::filesystem::path &path, const std::vector &expected_files) { - const auto archive_path = std::filesystem::path(R"(M:\observer\_test\)") / path; + const auto plugin = observer(); + const auto files = plugin.list_files(path); + REQUIRE(files.size() == expected_files.size()); + + for (const auto &file : files) { + const auto expected = std::ranges::find(expected_files, file.path, &expected_file::path); + REQUIRE(expected != expected_files.end()); + + temporary_file extracted(make_unique_path(path, file)); + file.extract(extracted.path()); + REQUIRE(file.uncompressed_size >= 0); + REQUIRE(std::filesystem::file_size(extracted.path()) == + static_cast(file.uncompressed_size)); + REQUIRE(read_file(extracted.path()) == expected->contents); + } + } + + void test_external_archive(const std::filesystem::path &path) + { + const auto required_size = GetEnvironmentVariableW(L"OBSERVER_TEST_CORPUS", nullptr, 0); + if (required_size == 0) { + SKIP("OBSERVER_TEST_CORPUS is not set; external golden corpus test skipped"); + } + + std::wstring corpus_root(required_size, L'\0'); + const auto written = GetEnvironmentVariableW(L"OBSERVER_TEST_CORPUS", corpus_root.data(), required_size); + REQUIRE(written > 0); + REQUIRE(written < required_size); + corpus_root.resize(written); + + const auto archive_path = std::filesystem::path(corpus_root) / path; const auto folder = archive_path.parent_path(); std::ifstream expected_listing(folder / std::format(L"{}.expected.json", archive_path.stem().wstring())); REQUIRE(expected_listing.is_open()); auto listing_json = nlohmann::json::parse(expected_listing); - auto expected_strings = listing_json.get >(); + auto expected_strings = listing_json.get>(); std::ranges::sort(expected_strings); const auto plugin = observer(); std::vector actual_strings; - for (const auto files = plugin.list_files(archive_path); const auto &file: files) { - const auto path_on_disk = make_unique_path(archive_path, file); - file.extract(path_on_disk); - const auto file_size = std::filesystem::file_size(path_on_disk); - REQUIRE(file_size == file.uncompressed_size); - actual_strings.emplace_back(std::format("{} {} {}", wide_to_utf8(file.path), file_size, - hash_file(path_on_disk))); - REQUIRE(std::filesystem::remove(path_on_disk)); + for (const auto files = plugin.list_files(archive_path); const auto &file : files) { + temporary_file extracted(make_unique_path(archive_path, file)); + file.extract(extracted.path()); + const auto file_size = std::filesystem::file_size(extracted.path()); + REQUIRE(file.uncompressed_size >= 0); + REQUIRE(file_size == static_cast(file.uncompressed_size)); + actual_strings.emplace_back( + std::format("{} {} {}", wide_to_utf8(file.path), file_size, hash_file(extracted.path()))); } std::ranges::sort(actual_strings); - std::ofstream actual_listing(folder / std::format(L"{}.actual.json", archive_path.stem().wstring())); - REQUIRE(actual_listing.is_open()); - actual_listing << std::setw(4) << nlohmann::json(actual_strings) << std::endl; - REQUIRE(expected_strings.size() == actual_strings.size()); for (size_t i = 0; i < expected_strings.size(); i++) { REQUIRE(expected_strings[i] == actual_strings[i]); } } -} +} // namespace test diff --git a/src/tests/framework/testcase.h b/src/tests/framework/testcase.h index f3cfda8..434225c 100644 --- a/src/tests/framework/testcase.h +++ b/src/tests/framework/testcase.h @@ -1,8 +1,17 @@ #pragma once #include +#include +#include namespace test { - void test_on(const std::filesystem::path &path); -} + struct expected_file final + { + std::wstring path; + std::string contents; + }; + + void test_archive(const std::filesystem::path &path, const std::vector &expected_files); + void test_external_archive(const std::filesystem::path &path); +} // namespace test diff --git a/src/tests/integration/archives.cpp b/src/tests/integration/archives.cpp new file mode 100644 index 0000000..52dbfcf --- /dev/null +++ b/src/tests/integration/archives.cpp @@ -0,0 +1,68 @@ +#include "../framework/testcase.h" +#include "../support/archive_fixtures.h" + +#include + +#include + +TEST_CASE("archives: hermetic RenPy RPA 2.0", "[integration][hermetic]") +{ + const auto contents = test::support::make_renpy_archive("dir/hello.txt", "renpy payload"); + const test::support::temporary_archive archive("renpy", contents); + test::test_archive(archive.path(), {{L"dir\\hello.txt", "renpy payload"}}); +} + +TEST_CASE("archives: hermetic RenPy RPA 3.0", "[integration][hermetic]") +{ + test::support::renpy_archive_options options; + options.version = test::support::renpy_version::rpa_3_0; + const auto contents = test::support::make_renpy_archive("dir/encrypted.txt", "encrypted payload", options); + const test::support::temporary_archive archive("renpy-v3", contents); + test::test_archive(archive.path(), {{L"dir\\encrypted.txt", "encrypted payload"}}); +} + +TEST_CASE("archives: RenPy prepends an indexed header", "[integration][hermetic]") +{ + test::support::renpy_archive_options options; + options.header = "header:"; + const auto contents = test::support::make_renpy_archive("header.txt", "payload", options); + const test::support::temporary_archive archive("renpy-header", contents); + test::test_archive(archive.path(), {{L"header.txt", "header:payload"}}); +} + +TEST_CASE("archives: RenPy accepts an explicit empty header", "[integration][hermetic]") +{ + test::support::renpy_archive_options options; + options.include_none_header = true; + const auto contents = test::support::make_renpy_archive("no-header.txt", "payload", options); + const test::support::temporary_archive archive("renpy-no-header", contents); + test::test_archive(archive.path(), {{L"no-header.txt", "payload"}}); +} + +TEST_CASE("archives: hermetic RPG Maker RGSS3A", "[integration][hermetic]") +{ + const auto contents = test::support::make_rpgmaker_archive("Data\\hello.txt", "rpgmaker payload"); + const test::support::temporary_archive archive("rpgmaker", contents); + test::test_archive(archive.path(), {{L"Data\\hello.txt", "rpgmaker payload"}}); +} + +TEST_CASE("archives: RPG Maker decrypts a partial final word", "[integration][hermetic]") +{ + const auto contents = test::support::make_rpgmaker_archive("Data\\tail.txt", "tail!"); + const test::support::temporary_archive archive("rpgmaker-tail", contents); + test::test_archive(archive.path(), {{L"Data\\tail.txt", "tail!"}}); +} + +TEST_CASE("archives: hermetic Zanzarah PAK", "[integration][hermetic]") +{ + const auto contents = test::support::make_zanzarah_archive("..\\data\\hello.txt", "zanzarah payload"); + const test::support::temporary_archive archive("zanzarah", contents); + test::test_archive(archive.path(), {{L"data\\hello.txt", "zanzarah payload"}}); +} + +TEST_CASE("archives: Zanzarah preserves an already-relative path", "[integration][hermetic]") +{ + const auto contents = test::support::make_zanzarah_archive("data\\direct.txt", "direct payload"); + const test::support::temporary_archive archive("zanzarah-relative", contents); + test::test_archive(archive.path(), {{L"data\\direct.txt", "direct payload"}}); +} diff --git a/src/tests/leaks/probe.cpp b/src/tests/leaks/probe.cpp new file mode 100644 index 0000000..da1541f --- /dev/null +++ b/src/tests/leaks/probe.cpp @@ -0,0 +1,681 @@ +#include "../../api.h" +#include "../support/archive_fixtures.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef _DEBUG +#error "leak-probe must be built as the optimized Release executable" +#endif + +namespace +{ + constexpr std::string_view marker_prefix = "OBSERVER_LEAK_PROBE"; + enum class probe_mode : std::uint8_t + { + operations, + lifecycle, + }; + + struct options final + { + std::size_t warmup_rounds = 8; + std::size_t iterations_per_window = 100; + std::size_t windows = 3; + probe_mode mode = probe_mode::operations; + std::string_view scenario = "all"; + bool automatic = false; + }; + + [[nodiscard]] std::size_t parse_count(const std::string_view value, const std::string_view option) + { + std::size_t result = 0; + const auto [end, error] = std::from_chars(value.data(), value.data() + value.size(), result); + if (error != std::errc{} || end != value.data() + value.size() || result == 0 || result > 1'000'000) { + throw std::runtime_error(std::format("{} expects an integer from 1 to 1000000", option)); + } + return result; + } + + [[nodiscard]] options parse_options(const int argc, char **argv) + { + options result; + for (int index = 1; index < argc; ++index) { + const std::string_view argument(argv[index]); + if (argument == "--automatic") { + result.automatic = true; + continue; + } + + if (argument == "--help") { + std::cout << "Usage: leak-probe.exe [--automatic] [--mode operations|lifecycle] " + "[--scenario all|NAME] [--warmup N] [--iterations N] [--windows N]\n"; + std::exit(EXIT_SUCCESS); + } + + if (argument != "--mode" && argument != "--scenario" && argument != "--warmup" && + argument != "--iterations" && argument != "--windows") { + throw std::runtime_error(std::format("Unknown option: {}", argument)); + } + if (++index >= argc) { + throw std::runtime_error(std::format("Missing value after {}", argument)); + } + + if (argument == "--mode") { + const std::string_view mode(argv[index]); + if (mode == "operations") { + result.mode = probe_mode::operations; + } else if (mode == "lifecycle") { + result.mode = probe_mode::lifecycle; + } else { + throw std::runtime_error("--mode expects operations or lifecycle"); + } + } else if (argument == "--scenario") { + result.scenario = argv[index]; + } else if (argument == "--warmup") { + const auto count = parse_count(argv[index], argument); + result.warmup_rounds = count; + } else if (argument == "--iterations") { + const auto count = parse_count(argv[index], argument); + result.iterations_per_window = count; + } else { + const auto count = parse_count(argv[index], argument); + result.windows = count; + } + } + return result; + } + + void suppress_error_dialogs() noexcept + { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); + } + + [[nodiscard]] std::filesystem::path executable_directory() + { + std::wstring path(32'768, L'\0'); + const auto length = GetModuleFileNameW(nullptr, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) { + throw std::runtime_error("Failed to locate leak-probe.exe"); + } + path.resize(length); + return std::filesystem::path(path).parent_path(); + } + + template [[nodiscard]] Function resolve_export(const HMODULE module, const char *name) + { + const auto address = GetProcAddress(module, name); + if (address == nullptr) { + throw std::runtime_error(std::format("Required module export is absent: {}", name)); + } +#pragma warning(suppress : 4191) + return reinterpret_cast(address); + } + + class storage_handle final + { + public: + storage_handle(const module_cbs &api, const HANDLE value) noexcept : api_(api), value_(value) + { + } + + ~storage_handle() + { + if (value_ != nullptr) { + api_.CloseStorage(value_); + } + } + + storage_handle(const storage_handle &) = delete; + storage_handle &operator=(const storage_handle &) = delete; + storage_handle(storage_handle &&other) noexcept : api_(other.api_), value_(other.value_) + { + other.value_ = nullptr; + } + + [[nodiscard]] HANDLE get() const noexcept + { + return value_; + } + + private: + const module_cbs &api_; + HANDLE value_; + }; + + class temporary_output final + { + public: + explicit temporary_output(std::filesystem::path path) : path_(std::move(path)) + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + ~temporary_output() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + [[nodiscard]] const std::filesystem::path &path() const noexcept + { + return path_; + } + + private: + std::filesystem::path path_; + }; + + [[nodiscard]] BOOL CALLBACK report_progress(const HANDLE context, const __int64 bytes_processed) noexcept + { + if (context == nullptr || bytes_processed < 0) { + return FALSE; + } + auto &total = *static_cast<__int64 *>(context); + total += bytes_processed; + return TRUE; + } + + class loaded_module final + { + public: + explicit loaded_module(const std::filesystem::path &path) + : library_(LoadLibraryExW(path.c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)) + { + if (library_ == nullptr) { + throw std::runtime_error( + std::format("Failed to load {} (Win32 error {})", path.filename().string(), GetLastError())); + } + + try { + const auto expected_path = std::filesystem::canonical(path); + const auto actual_path = std::filesystem::canonical(module_path(library_)); + if (!std::filesystem::equivalent(expected_path, actual_path)) { + throw std::runtime_error(std::format("Loaded module path mismatch: expected {}, received {}", + expected_path.string(), actual_path.string())); + } + + const auto load = resolve_export(library_, "LoadSubModule"); + unload_ = resolve_export(library_, "UnloadSubModule"); + + ModuleLoadParameters parameters{}; + parameters.StructSize = sizeof(parameters); + if (load(¶meters) == FALSE) { + throw std::runtime_error(std::format("LoadSubModule failed for {}", path.filename().string())); + } + loaded_ = true; + api_ = parameters.ApiFuncs; + + if (parameters.ApiVersion != ACTUAL_API_VERSION || api_.OpenStorage == nullptr || + api_.CloseStorage == nullptr || api_.GetItem == nullptr || api_.ExtractItem == nullptr || + api_.PrepareFiles == nullptr) { + throw std::runtime_error( + std::format("Invalid Observer API table from {}", path.filename().string())); + } + } catch (...) { + release(); + throw; + } + } + + ~loaded_module() + { + release(); + } + + loaded_module(const loaded_module &) = delete; + loaded_module &operator=(const loaded_module &) = delete; + + void exercise_success(const std::filesystem::path &archive_path, const std::wstring_view expected_path, + const std::string_view expected_payload, const std::filesystem::path &output_path) const + { + auto storage = open_archive(archive_path); + + if (api_.PrepareFiles(storage.get()) == FALSE) { + throw std::runtime_error("PrepareFiles failed for a hermetic fixture"); + } + + StorageItemInfo item{}; + if (api_.GetItem(storage.get(), 0, &item) != GET_ITEM_OK) { + throw std::runtime_error("GetItem failed for the only expected fixture entry"); + } + if (std::wstring_view(item.Path) != expected_path || item.Size < 0) { + throw std::runtime_error("The fixture entry metadata is incorrect"); + } + StorageItemInfo unexpected_item{}; + if (api_.GetItem(storage.get(), 1, &unexpected_item) != GET_ITEM_NOMOREITEMS) { + throw std::runtime_error("The fixture unexpectedly contains more than one entry"); + } + + temporary_output output(output_path); + __int64 progress = 0; + const ExtractOperationParams extract_parameters{ + .ItemIndex = 0, + .Flags = 0, + .DestPath = output.path().c_str(), + .Password = nullptr, + .Callbacks = {.signalContext = &progress, .FileProgress = report_progress}, + }; + if (api_.ExtractItem(storage.get(), extract_parameters) != SER_SUCCESS) { + throw std::runtime_error("ExtractItem failed for a hermetic fixture"); + } + + std::ifstream extracted(output.path(), std::ios::binary); + if (!extracted.is_open()) { + throw std::runtime_error("ExtractItem did not create its output file"); + } + const std::string actual_payload{std::istreambuf_iterator(extracted), + std::istreambuf_iterator()}; + if (actual_payload != expected_payload || static_cast(item.Size) != actual_payload.size() || + progress <= 0) { + throw std::runtime_error("Extracted fixture data is incorrect"); + } + } + + void expect_prepare_failure(const std::filesystem::path &archive_path) const + { + auto storage = open_archive(archive_path); + if (api_.PrepareFiles(storage.get()) != FALSE) { + throw std::runtime_error("PrepareFiles unexpectedly accepted a malformed fixture"); + } + } + + void expect_extract_status(const std::filesystem::path &archive_path, const std::filesystem::path &output_path, + const ExtractProgressFunc progress_callback, const int expected_status) const + { + auto storage = open_archive(archive_path); + if (api_.PrepareFiles(storage.get()) == FALSE) { + throw std::runtime_error("PrepareFiles rejected an extraction-status fixture"); + } + + __int64 progress = 0; + const ExtractOperationParams extract_parameters{ + .ItemIndex = 0, + .Flags = 0, + .DestPath = output_path.c_str(), + .Password = nullptr, + .Callbacks = {.signalContext = &progress, .FileProgress = progress_callback}, + }; + const auto actual_status = api_.ExtractItem(storage.get(), extract_parameters); + if (actual_status != expected_status) { + throw std::runtime_error( + std::format("ExtractItem returned {}, expected {}", actual_status, expected_status)); + } + } + + void expect_entry_count(const std::filesystem::path &archive_path, const std::size_t expected_count) const + { + if (expected_count > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Expected entry count is outside the Observer ABI range"); + } + + auto storage = open_archive(archive_path); + if (api_.PrepareFiles(storage.get()) == FALSE) { + throw std::runtime_error("PrepareFiles rejected a large valid metadata fixture"); + } + for (std::size_t index = 0; index < expected_count; ++index) { + StorageItemInfo item{}; + if (api_.GetItem(storage.get(), static_cast(index), &item) != GET_ITEM_OK || item.Size != 1 || + item.PackedSize != 1 || item.Path[0] == L'\0') { + throw std::runtime_error("GetItem returned invalid large-fixture metadata"); + } + } + StorageItemInfo unexpected_item{}; + if (api_.GetItem(storage.get(), static_cast(expected_count), &unexpected_item) != + GET_ITEM_NOMOREITEMS) { + throw std::runtime_error("The large metadata fixture contains an unexpected extra entry"); + } + } + + private: + [[nodiscard]] static std::filesystem::path module_path(const HMODULE module) + { + std::wstring path(32'768, L'\0'); + const auto length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) { + throw std::runtime_error("Failed to resolve the loaded module path"); + } + path.resize(length); + return path; + } + + [[nodiscard]] storage_handle open_archive(const std::filesystem::path &archive_path) const + { + std::ifstream archive(archive_path, std::ios::binary); + if (!archive.is_open()) { + throw std::runtime_error("Failed to open a hermetic archive fixture"); + } + + std::vector signature(std::size_t{128} * 1024); + archive.read(signature.data(), static_cast(signature.size())); + if (archive.bad()) { + throw std::runtime_error("Failed to read a hermetic archive fixture"); + } + + StorageOpenParams open_parameters{ + .StructSize = sizeof(StorageOpenParams), + .FilePath = archive_path.c_str(), + .Password = nullptr, + .Data = signature.data(), + .DataSize = static_cast(archive.gcount()), + }; + StorageGeneralInfo general_info{}; + HANDLE raw_storage = nullptr; + if (api_.OpenStorage(open_parameters, &raw_storage, &general_info) != SOR_SUCCESS || + raw_storage == nullptr) { + throw std::runtime_error("OpenStorage rejected its hermetic fixture"); + } + return storage_handle(api_, raw_storage); + } + + void release() noexcept + { + if (loaded_ && unload_ != nullptr) { + unload_(); + loaded_ = false; + } + unload_ = nullptr; + if (library_ != nullptr) { + FreeLibrary(library_); + library_ = nullptr; + } + } + + HMODULE library_ = nullptr; + bool loaded_ = false; + UnloadSubModuleFunc unload_ = nullptr; + module_cbs api_{}; + }; + + [[nodiscard]] test::support::byte_buffer bytes(const std::string_view value) + { + test::support::byte_buffer result; + result.reserve(value.size()); + std::ranges::transform(value, std::back_inserter(result), [](const char character) { + return static_cast(static_cast(character)); + }); + return result; + } + + [[nodiscard]] BOOL CALLBACK cancel_progress(HANDLE, __int64) noexcept + { + return FALSE; + } + + struct probe_fixtures final + { + static constexpr std::size_t large_entry_count = 4'096; + static constexpr std::size_t expanded_index_size = 64ULL * 1024 * 1024 + 1; + + probe_fixtures() + : temporary_directory(std::filesystem::temp_directory_path()), + renpy_output(temporary_directory / + std::format("observer-leak-probe-renpy-{}.tmp", GetCurrentProcessId())), + rpgmaker_output(temporary_directory / + std::format("observer-leak-probe-rpgmaker-{}.tmp", GetCurrentProcessId())), + zanzarah_output(temporary_directory / + std::format("observer-leak-probe-zanzarah-{}.tmp", GetCurrentProcessId())), + cancellation_output(temporary_directory / + std::format("observer-leak-probe-cancellation-{}.tmp", GetCurrentProcessId())), + read_failure_output(temporary_directory / + std::format("observer-leak-probe-read-failure-{}.tmp", GetCurrentProcessId())), + renpy_archive("renpy", test::support::make_renpy_archive("dir/hello.txt", "renpy payload")), + rpgmaker_archive("rpgmaker", test::support::make_rpgmaker_archive("Data\\hello.txt", "rpgmaker payload")), + zanzarah_archive("zanzarah", + test::support::make_zanzarah_archive("..\\data\\hello.txt", "zanzarah payload")), + malformed_renpy_archive("malformed-renpy", bytes("RPA-2.0 0000000000000000\n")), + malformed_rpgmaker_archive("malformed-rpgmaker", bytes(std::string{"RGSSAD\0\3", 8})), + malformed_zanzarah_archive("malformed-zanzarah", test::support::byte_buffer(8, 0)), + cancellation_archive("cancellation", test::support::make_rpgmaker_archive( + "abort.txt", std::string(std::size_t{256} * 1024, 'x'))), + read_failure_archive("read-failure", + test::support::make_renpy_archive_with_index(test::support::byte_buffer{ + '}', 'U', 1, 'x', ']', 'K', 0, 'K', 100, 0x86, 'a', 's', '.'})), + large_metadata_archive("large-metadata", + test::support::make_zanzarah_archive_with_entries(large_entry_count)), + expanded_metadata_archive("expanded-metadata", + test::support::make_renpy_archive_with_expanded_index(expanded_index_size)) + { + } + + std::filesystem::path temporary_directory; + std::filesystem::path renpy_output; + std::filesystem::path rpgmaker_output; + std::filesystem::path zanzarah_output; + std::filesystem::path cancellation_output; + std::filesystem::path read_failure_output; + test::support::temporary_archive renpy_archive; + test::support::temporary_archive rpgmaker_archive; + test::support::temporary_archive zanzarah_archive; + test::support::temporary_archive malformed_renpy_archive; + test::support::temporary_archive malformed_rpgmaker_archive; + test::support::temporary_archive malformed_zanzarah_archive; + test::support::temporary_archive cancellation_archive; + test::support::temporary_archive read_failure_archive; + test::support::temporary_archive large_metadata_archive; + test::support::temporary_archive expanded_metadata_archive; + test::support::temporary_sparse_renpy_archive sparse_metadata_archive; + }; + + struct module_set final + { + explicit module_set(const std::filesystem::path &binary_directory) + : renpy(binary_directory / "renpy.so"), rpgmaker(binary_directory / "rpgmaker.so"), + zanzarah(binary_directory / "zanzarah.so") + { + } + + loaded_module renpy; + loaded_module rpgmaker; + loaded_module zanzarah; + }; + + struct scenario_context final + { + const module_set &modules; + const probe_fixtures &fixtures; + }; + + void exercise_small_success(const scenario_context &context) + { + context.modules.renpy.exercise_success(context.fixtures.renpy_archive.path(), L"dir\\hello.txt", + "renpy payload", context.fixtures.renpy_output); + context.modules.rpgmaker.exercise_success(context.fixtures.rpgmaker_archive.path(), L"Data\\hello.txt", + "rpgmaker payload", context.fixtures.rpgmaker_output); + context.modules.zanzarah.exercise_success(context.fixtures.zanzarah_archive.path(), L"data\\hello.txt", + "zanzarah payload", context.fixtures.zanzarah_output); + } + + void exercise_malformed(const scenario_context &context) + { + context.modules.renpy.expect_prepare_failure(context.fixtures.malformed_renpy_archive.path()); + context.modules.rpgmaker.expect_prepare_failure(context.fixtures.malformed_rpgmaker_archive.path()); + context.modules.zanzarah.expect_prepare_failure(context.fixtures.malformed_zanzarah_archive.path()); + } + + void exercise_cancellation(const scenario_context &context) + { + const temporary_output output(context.fixtures.cancellation_output); + context.modules.rpgmaker.expect_extract_status(context.fixtures.cancellation_archive.path(), output.path(), + cancel_progress, SER_USERABORT); + } + + void exercise_read_failure(const scenario_context &context) + { + const temporary_output output(context.fixtures.read_failure_output); + context.modules.renpy.expect_extract_status(context.fixtures.read_failure_archive.path(), output.path(), + report_progress, SER_ERROR_READ); + } + + void exercise_write_failure(const scenario_context &context) + { + context.modules.rpgmaker.expect_extract_status(context.fixtures.rpgmaker_archive.path(), + context.fixtures.temporary_directory, report_progress, + SER_ERROR_WRITE); + } + + void exercise_large_metadata(const scenario_context &context) + { + context.modules.zanzarah.expect_entry_count(context.fixtures.large_metadata_archive.path(), + probe_fixtures::large_entry_count); + } + + void exercise_sparse_metadata(const scenario_context &context) + { + context.modules.renpy.expect_prepare_failure(context.fixtures.sparse_metadata_archive.path()); + context.modules.renpy.expect_prepare_failure(context.fixtures.expanded_metadata_archive.path()); + } + + struct scenario_definition final + { + std::string_view name; + void (*exercise)(const scenario_context &) = nullptr; + }; + + constexpr std::array scenario_suite{ + scenario_definition{"small-success", exercise_small_success}, + scenario_definition{"malformed", exercise_malformed}, + scenario_definition{"cancellation", exercise_cancellation}, + scenario_definition{"read-failure", exercise_read_failure}, + scenario_definition{"write-failure", exercise_write_failure}, + scenario_definition{"large-metadata", exercise_large_metadata}, + scenario_definition{"sparse-metadata", exercise_sparse_metadata}, + }; + + [[nodiscard]] const scenario_definition *select_scenario(const std::string_view name) + { + if (name == "all") { + return nullptr; + } + const auto selected = std::ranges::find_if( + scenario_suite, [name](const scenario_definition &scenario) { return scenario.name == name; }); + if (selected == scenario_suite.end()) { + throw std::runtime_error(std::format("Unknown leak scenario: {}", name)); + } + return &*selected; + } + + void exercise_scenario_suite(const module_set &modules, const probe_fixtures &fixtures, + const scenario_definition *const selected_scenario) + { + const scenario_context context{modules, fixtures}; + if (selected_scenario != nullptr) { + selected_scenario->exercise(context); + return; + } + for (const auto &scenario : scenario_suite) { + scenario.exercise(context); + } + } + + void synchronize_snapshot(const std::string_view label, const std::size_t completed_operations, + const bool automatic) + { + std::cout << marker_prefix << "|SNAPSHOT|" << label << "|pid=" << GetCurrentProcessId() + << "|completed_operations=" << completed_operations << '\n' + << std::flush; + if (automatic) { + return; + } + + std::string response; + if (!std::getline(std::cin, response)) { + throw std::runtime_error(std::format("Snapshot {} was not acknowledged", label)); + } + const auto expected = std::format("continue|{}", label); + if (response != expected) { + throw std::runtime_error( + std::format("Expected snapshot acknowledgement '{}', received '{}'", expected, response)); + } + } + + template + void run_measurement_windows(const options &settings, Round &&exercise, const std::size_t operations_per_round) + { + std::size_t completed_operations = 0; + for (std::size_t round = 0; round < settings.warmup_rounds; ++round) { + exercise(); + completed_operations += operations_per_round; + } + synchronize_snapshot("baseline", completed_operations, settings.automatic); + + for (std::size_t window = 1; window <= settings.windows; ++window) { + for (std::size_t iteration = 0; iteration < settings.iterations_per_window; ++iteration) { + exercise(); + completed_operations += operations_per_round; + } + synchronize_snapshot(std::format("window-{}", window), completed_operations, settings.automatic); + } + + std::cout << marker_prefix << "|DONE|pid=" << GetCurrentProcessId() + << "|completed_operations=" << completed_operations << '\n' + << std::flush; + } +} // namespace + +int main(const int argc, char **argv) +{ + suppress_error_dialogs(); + + try { + const auto settings = parse_options(argc, argv); + const auto binary_directory = executable_directory(); + const auto mode_name = settings.mode == probe_mode::operations ? "operations" : "lifecycle"; + const auto *const selected_scenario = select_scenario(settings.scenario); + const auto operations_per_round = selected_scenario == nullptr ? scenario_suite.size() : std::size_t{1}; + const probe_fixtures fixtures; + std::cout << marker_prefix << "|READY|pid=" << GetCurrentProcessId() << "|mode=" << mode_name + << "|configuration=Release|scenarios="; + if (selected_scenario != nullptr) { + std::cout << selected_scenario->name; + } else { + for (std::size_t index = 0; index < scenario_suite.size(); ++index) { + if (index != 0) { + std::cout << ','; + } + std::cout << scenario_suite[index].name; + } + } + std::cout << '\n' << std::flush; + + if (settings.mode == probe_mode::operations) { + const module_set modules(binary_directory); + run_measurement_windows( + settings, + [&modules, &fixtures, selected_scenario] { + exercise_scenario_suite(modules, fixtures, selected_scenario); + }, + operations_per_round); + } else { + const auto exercise_lifecycle = [&binary_directory, &fixtures, selected_scenario] { + const module_set modules(binary_directory); + exercise_scenario_suite(modules, fixtures, selected_scenario); + }; + run_measurement_windows(settings, exercise_lifecycle, operations_per_round); + } + return EXIT_SUCCESS; + } catch (const std::exception &error) { + std::cerr << marker_prefix << "|ERROR|" << error.what() << '\n' << std::flush; + return EXIT_FAILURE; + } catch (...) { + std::cerr << marker_prefix << "|ERROR|unknown failure\n" << std::flush; + return EXIT_FAILURE; + } +} diff --git a/src/tests/main.cpp b/src/tests/main.cpp new file mode 100644 index 0000000..1e3b865 --- /dev/null +++ b/src/tests/main.cpp @@ -0,0 +1,22 @@ +#include + +#include +#include +#include + +int main(int argc, char *argv[]) +{ + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + +#ifdef _DEBUG + _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR); + _CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR); + _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE); + _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR); +#endif + + return Catch::Session().run(argc, argv); +} diff --git a/src/tests/renpy.cpp b/src/tests/renpy.cpp index 161ca37..9c78efe 100644 --- a/src/tests/renpy.cpp +++ b/src/tests/renpy.cpp @@ -4,72 +4,72 @@ using namespace test; -TEST_CASE("renpy: rpa20_binary_hearts") +TEST_CASE("renpy: rpa20_binary_hearts", "[compatibility][.]") { - test_on("renpy\\rpa20_binary_hearts.rpa"); + test_external_archive("renpy\\rpa20_binary_hearts.rpa"); } -TEST_CASE("renpy: rpa30_army_gals") +TEST_CASE("renpy: rpa30_army_gals", "[compatibility][.]") { - test_on("renpy\\rpa30_army_gals.rpa"); + test_external_archive("renpy\\rpa30_army_gals.rpa"); } -TEST_CASE("renpy: rpa30_catch_canvas") +TEST_CASE("renpy: rpa30_catch_canvas", "[compatibility][.]") { - test_on("renpy\\rpa30_catch_canvas.rpa"); + test_external_archive("renpy\\rpa30_catch_canvas.rpa"); } -TEST_CASE("renpy: rpa30_crimson_gray") +TEST_CASE("renpy: rpa30_crimson_gray", "[compatibility][.]") { - test_on("renpy\\rpa30_crimson_gray.rpa"); + test_external_archive("renpy\\rpa30_crimson_gray.rpa"); } -TEST_CASE("renpy: rpa30_crimson_gray_dusk_and_down") +TEST_CASE("renpy: rpa30_crimson_gray_dusk_and_down", "[compatibility][.]") { - test_on("renpy\\rpa30_crimson_gray_dusk_and_down.rpa"); + test_external_archive("renpy\\rpa30_crimson_gray_dusk_and_down.rpa"); } -TEST_CASE("renpy: rpa30_daydream") +TEST_CASE("renpy: rpa30_daydream", "[compatibility][.]") { - test_on("renpy\\rpa30_daydream.rpa"); + test_external_archive("renpy\\rpa30_daydream.rpa"); } -TEST_CASE("renpy: rpa30_doki_doki_high_school_love_time") +TEST_CASE("renpy: rpa30_doki_doki_high_school_love_time", "[compatibility][.]") { - test_on("renpy\\rpa30_doki_doki_high_school_love_time.rpa"); + test_external_archive("renpy\\rpa30_doki_doki_high_school_love_time.rpa"); } -TEST_CASE("renpy: rpa30_dont_take_this_risk") +TEST_CASE("renpy: rpa30_dont_take_this_risk", "[compatibility][.]") { - test_on("renpy\\rpa30_dont_take_this_risk.rpa"); + test_external_archive("renpy\\rpa30_dont_take_this_risk.rpa"); } -TEST_CASE("renpy: rpa30_exiles") +TEST_CASE("renpy: rpa30_exiles", "[compatibility][.]") { - test_on("renpy\\rpa30_exiles.rpa"); + test_external_archive("renpy\\rpa30_exiles.rpa"); } -TEST_CASE("renpy: rpa30_forest") +TEST_CASE("renpy: rpa30_forest", "[compatibility][.]") { - test_on("renpy\\rpa30_forest.rpa"); + test_external_archive("renpy\\rpa30_forest.rpa"); } -TEST_CASE("renpy: rpa30_lucy_got_problems") +TEST_CASE("renpy: rpa30_lucy_got_problems", "[compatibility][.]") { - test_on("renpy\\rpa30_lucy_got_problems.rpa"); + test_external_archive("renpy\\rpa30_lucy_got_problems.rpa"); } -TEST_CASE("renpy: rpa30_national_park_girls") +TEST_CASE("renpy: rpa30_national_park_girls", "[compatibility][.]") { - test_on("renpy\\rpa30_national_park_girls.rpa"); + test_external_archive("renpy\\rpa30_national_park_girls.rpa"); } -TEST_CASE("renpy: rpa30_resort") +TEST_CASE("renpy: rpa30_resort", "[compatibility][.]") { - test_on("renpy\\rpa30_resort.rpa"); + test_external_archive("renpy\\rpa30_resort.rpa"); } -TEST_CASE("renpy: rpa30_the_flower_shop") +TEST_CASE("renpy: rpa30_the_flower_shop", "[compatibility][.]") { - test_on("renpy\\rpa30_the_flower_shop.rpa"); + test_external_archive("renpy\\rpa30_the_flower_shop.rpa"); } diff --git a/src/tests/rpgmaker.cpp b/src/tests/rpgmaker.cpp index 3e7840c..fd080d3 100644 --- a/src/tests/rpgmaker.cpp +++ b/src/tests/rpgmaker.cpp @@ -4,47 +4,47 @@ using namespace test; -TEST_CASE("rpgmaker: GirlsXLust_v1.0") +TEST_CASE("rpgmaker: GirlsXLust_v1.0", "[compatibility][.]") { - test_on("rpgmaker\\GirlsXLust_v1.0.rgss3a"); + test_external_archive("rpgmaker\\GirlsXLust_v1.0.rgss3a"); } -TEST_CASE("rpgmaker: Maid-X-Demon-Mari-s-First-Job") +TEST_CASE("rpgmaker: Maid-X-Demon-Mari-s-First-Job", "[compatibility][.]") { - test_on("rpgmaker\\Maid-X-Demon-Mari-s-First-Job.rgss3a"); + test_external_archive("rpgmaker\\Maid-X-Demon-Mari-s-First-Job.rgss3a"); } -TEST_CASE("rpgmaker: MaiDensnow_Eve") +TEST_CASE("rpgmaker: MaiDensnow_Eve", "[compatibility][.]") { - test_on("rpgmaker\\MaiDensnow_Eve.rgss3a"); + test_external_archive("rpgmaker\\MaiDensnow_Eve.rgss3a"); } -TEST_CASE("rpgmaker: MaidsPerfect_v1.0a") +TEST_CASE("rpgmaker: MaidsPerfect_v1.0a", "[compatibility][.]") { - test_on("rpgmaker\\MaidsPerfect_v1.0a.rgss3a"); + test_external_archive("rpgmaker\\MaidsPerfect_v1.0a.rgss3a"); } -TEST_CASE("rpgmaker: NotSoOrdinaryStory") +TEST_CASE("rpgmaker: NotSoOrdinaryStory", "[compatibility][.]") { - test_on("rpgmaker\\NotSoOrdinaryStory.rgss3a"); + test_external_archive("rpgmaker\\NotSoOrdinaryStory.rgss3a"); } -TEST_CASE("rpgmaker: A_Song_of_Elfpai_and_Tentacles_1.20_ENGLISH_") +TEST_CASE("rpgmaker: A_Song_of_Elfpai_and_Tentacles_1.20_ENGLISH_", "[compatibility][.]") { - test_on("rpgmaker\\RJ135050_-_A_Song_of_Elfpai_and_Tentacles_1.20_ENGLISH_.rgss3a"); + test_external_archive("rpgmaker\\RJ135050_-_A_Song_of_Elfpai_and_Tentacles_1.20_ENGLISH_.rgss3a"); } -TEST_CASE("rpgmaker: ThroughTheStaticAlpha_v0.1") +TEST_CASE("rpgmaker: ThroughTheStaticAlpha_v0.1", "[compatibility][.]") { - test_on("rpgmaker\\ThroughTheStaticAlpha_v0.1.rgss3a"); + test_external_archive("rpgmaker\\ThroughTheStaticAlpha_v0.1.rgss3a"); } -TEST_CASE("rpgmaker: WarlockAndBoobs_v0.350.1.4") +TEST_CASE("rpgmaker: WarlockAndBoobs_v0.350.1.4", "[compatibility][.]") { - test_on("rpgmaker\\WarlockAndBoobs_v0.350.1.4.rgss3a"); + test_external_archive("rpgmaker\\WarlockAndBoobs_v0.350.1.4.rgss3a"); } -TEST_CASE("rpgmaker: WarlockAndBoobs_v0.422.0.1") +TEST_CASE("rpgmaker: WarlockAndBoobs_v0.422.0.1", "[compatibility][.]") { - test_on("rpgmaker\\WarlockAndBoobs_v0.422.0.1.rgss3a"); + test_external_archive("rpgmaker\\WarlockAndBoobs_v0.422.0.1.rgss3a"); } \ No newline at end of file diff --git a/src/tests/support/archive_fixtures.cpp b/src/tests/support/archive_fixtures.cpp new file mode 100644 index 0000000..6c64092 --- /dev/null +++ b/src/tests/support/archive_fixtures.cpp @@ -0,0 +1,366 @@ +#include "archive_fixtures.h" +#include "zlib_fixture.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace test::support +{ + namespace + { + class unique_handle final + { + public: + explicit unique_handle(const HANDLE value) noexcept : value_(value) + { + } + + ~unique_handle() + { + if (value_ != INVALID_HANDLE_VALUE) { + static_cast(CloseHandle(value_)); + } + } + + unique_handle(const unique_handle &) = delete; + unique_handle &operator=(const unique_handle &) = delete; + + [[nodiscard]] HANDLE get() const noexcept + { + return value_; + } + + private: + HANDLE value_; + }; + + class temporary_path_guard final + { + public: + explicit temporary_path_guard(const std::filesystem::path &path) noexcept : path_(path) + { + } + + ~temporary_path_guard() + { + if (!retained_) { + std::error_code error; + std::filesystem::remove(path_, error); + } + } + + temporary_path_guard(const temporary_path_guard &) = delete; + temporary_path_guard &operator=(const temporary_path_guard &) = delete; + + void retain() noexcept + { + retained_ = true; + } + + private: + const std::filesystem::path &path_; + bool retained_ = false; + }; + + void append_u32(byte_buffer &output, const std::uint32_t value) + { + output.push_back(static_cast(value)); + output.push_back(static_cast(value >> 8)); + output.push_back(static_cast(value >> 16)); + output.push_back(static_cast(value >> 24)); + } + + void append_bytes(byte_buffer &output, const std::string_view value) + { + output.reserve(output.size() + value.size()); + std::ranges::transform(value, std::back_inserter(output), [](const char character) { + return static_cast(static_cast(character)); + }); + } + + [[nodiscard]] std::uint32_t checked_u32(const std::size_t value, const std::string_view description) + { + if (value > std::numeric_limits::max()) { + throw std::runtime_error(std::format("{} does not fit in an archive field", description)); + } + return static_cast(value); + } + + [[nodiscard]] std::uint32_t checked_u32_sum(const std::size_t value, const std::size_t increment, + const std::string_view description) + { + if (value > std::numeric_limits::max() - increment) { + throw std::runtime_error(std::format("{} does not fit in an archive field", description)); + } + return static_cast(value + increment); + } + + [[nodiscard]] std::uint8_t checked_u8(const std::size_t value, const std::string_view description) + { + if (value > std::numeric_limits::max()) { + throw std::runtime_error(std::format("{} does not fit in the minimal Ren'Py fixture", description)); + } + return static_cast(value); + } + + [[nodiscard]] byte_buffer build_renpy_archive(const std::span pickle_index, + const std::string_view payload, const renpy_version version, + const std::uint8_t encryption_key, const std::size_t data_offset) + { + const auto pickle_bytes = std::as_bytes(pickle_index); + const auto compressed = compress_zlib_fixture(pickle_bytes); + + const auto index_offset = data_offset + payload.size(); + byte_buffer archive; + if (version == renpy_version::rpa_2_0) { + append_bytes(archive, std::format("RPA-2.0 {:016x}\n", index_offset)); + } else { + append_bytes(archive, std::format("RPA-3.0 {:016x} {:08x}\n", index_offset, encryption_key)); + } + if (archive.size() > data_offset) { + throw std::runtime_error("Ren'Py fixture data offset overlaps its header"); + } + archive.resize(data_offset, 0); + append_bytes(archive, payload); + std::ranges::transform(compressed, std::back_inserter(archive), + [](const auto byte) { return std::to_integer(byte); }); + return archive; + } + } // namespace + + temporary_archive::temporary_archive(const std::string_view label, const std::span contents) + { + if (contents.size() > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Fixture is too large for std::ofstream"); + } + + static std::atomic_uint32_t sequence = 0; + path_ = std::filesystem::temp_directory_path() / + std::format("observer-modules-{}-{}-{}.bin", label, GetCurrentProcessId(), sequence.fetch_add(1)); + + std::ofstream output(path_, std::ios::binary | std::ios::trunc); + if (!output.is_open()) { + throw std::runtime_error(std::format("Failed to create fixture: {}", path_.string())); + } + output.write(reinterpret_cast(contents.data()), static_cast(contents.size())); + if (!output.good()) { + output.close(); + std::error_code error; + std::filesystem::remove(path_, error); + throw std::runtime_error(std::format("Failed to write fixture: {}", path_.string())); + } + } + + temporary_archive::~temporary_archive() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path &temporary_archive::path() const noexcept + { + return path_; + } + + temporary_sparse_renpy_archive::temporary_sparse_renpy_archive() + { + static std::atomic_uint32_t sequence = 0; + path_ = std::filesystem::temp_directory_path() / + std::format("observer-modules-renpy-sparse-{}-{}.bin", GetCurrentProcessId(), sequence.fetch_add(1)); + temporary_path_guard path_guard(path_); + + const unique_handle output( + CreateFileW(path_.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (output.get() == INVALID_HANDLE_VALUE) { + throw std::runtime_error( + std::format("Failed to create sparse Ren'Py fixture (Win32 error {})", GetLastError())); + } + + DWORD bytes_returned = 0; + if (DeviceIoControl(output.get(), FSCTL_SET_SPARSE, nullptr, 0, nullptr, 0, &bytes_returned, nullptr) == + FALSE) { + throw std::runtime_error( + std::format("Failed to mark a Ren'Py fixture sparse (Win32 error {})", GetLastError())); + } + + constexpr std::string_view header_text = "RPA-2.0 0000000000000020\n"; + std::array header{}; + std::ranges::copy(header_text, header.begin()); + DWORD bytes_written = 0; + if (WriteFile(output.get(), header.data(), static_cast(header.size()), &bytes_written, nullptr) == + FALSE || + bytes_written != header.size()) { + throw std::runtime_error( + std::format("Failed to write a sparse Ren'Py fixture (Win32 error {})", GetLastError())); + } + + constexpr LONGLONG max_compressed_index_size = 64LL * 1024 * 1024; + const LARGE_INTEGER logical_end{.QuadPart = + static_cast(header.size()) + max_compressed_index_size + 1}; + if (SetFilePointerEx(output.get(), logical_end, nullptr, FILE_BEGIN) == FALSE || + SetEndOfFile(output.get()) == FALSE) { + throw std::runtime_error( + std::format("Failed to size a sparse Ren'Py fixture (Win32 error {})", GetLastError())); + } + path_guard.retain(); + } + + temporary_sparse_renpy_archive::~temporary_sparse_renpy_archive() + { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path &temporary_sparse_renpy_archive::path() const noexcept + { + return path_; + } + + byte_buffer make_zanzarah_archive(const std::string_view path, const std::string_view payload) + { + byte_buffer archive(4, 0); + append_u32(archive, 1); + append_u32(archive, checked_u32(path.size(), "Zanzarah path length")); + append_bytes(archive, path); + append_u32(archive, 0); + append_u32(archive, checked_u32_sum(payload.size(), 4, "Zanzarah payload length")); + append_u32(archive, 0x12345678); + append_bytes(archive, payload); + return archive; + } + + byte_buffer make_zanzarah_archive_with_entries(const std::size_t entry_count) + { + constexpr std::size_t block_size = 5; + if (entry_count == 0 || entry_count > static_cast(std::numeric_limits::max()) || + entry_count > static_cast(std::numeric_limits::max()) / block_size) { + throw std::runtime_error("Zanzarah fixture entry count is outside the representable range"); + } + + byte_buffer archive(4, 0); + append_u32(archive, checked_u32(entry_count, "Zanzarah entry count")); + for (std::size_t index = 0; index < entry_count; ++index) { + const auto path = std::format("data/file-{:06}.bin", index); + append_u32(archive, checked_u32(path.size(), "Zanzarah path length")); + append_bytes(archive, path); + append_u32(archive, checked_u32(index * block_size, "Zanzarah block offset")); + append_u32(archive, static_cast(block_size)); + } + + for (std::size_t index = 0; index < entry_count; ++index) { + append_u32(archive, 0x12345678); + archive.push_back(static_cast('a' + index % 26)); + } + return archive; + } + + byte_buffer make_rpgmaker_archive(const std::string_view path, const std::string_view payload) + { + byte_buffer archive{'R', 'G', 'S', 'S', 'A', 'D', 0, 3}; + constexpr std::uint32_t seed = 1; + constexpr std::uint32_t file_magic = 0x12345678; + constexpr std::uint32_t table_magic = seed * 9 + 3; + const auto data_offset = checked_u32_sum(path.size(), 32, "RPG Maker data offset"); + + append_u32(archive, seed); + append_u32(archive, data_offset ^ table_magic); + append_u32(archive, checked_u32(payload.size(), "RPG Maker payload length") ^ table_magic); + append_u32(archive, file_magic ^ table_magic); + append_u32(archive, checked_u32(path.size(), "RPG Maker path length") ^ table_magic); + for (std::size_t index = 0; index < path.size(); ++index) { + const auto key = static_cast(table_magic >> (8 * (index % 4))); + archive.push_back(static_cast(static_cast(path[index])) ^ key); + } + append_u32(archive, table_magic); + + byte_buffer encrypted; + encrypted.reserve(payload.size()); + append_bytes(encrypted, payload); + auto magic = file_magic; + std::size_t index = 0; + while (index + 4 <= encrypted.size()) { + for (std::size_t byte_index = 0; byte_index < 4; ++byte_index) { + encrypted[index + byte_index] ^= static_cast(magic >> (8 * byte_index)); + } + magic = magic * 7 + 3; + index += 4; + } + while (index < encrypted.size()) { + encrypted[index] ^= static_cast(magic >> (8 * (index % 4))); + ++index; + } + archive.insert(archive.end(), encrypted.begin(), encrypted.end()); + return archive; + } + + byte_buffer make_renpy_archive(const std::string_view path, const std::string_view payload) + { + return make_renpy_archive(path, payload, {}); + } + + byte_buffer make_renpy_archive(const std::string_view path, const std::string_view payload, + const renpy_archive_options &options) + { + constexpr std::uint8_t tuple2 = 0x86; + constexpr std::uint8_t tuple3 = 0x87; + const std::size_t data_offset = options.version == renpy_version::rpa_2_0 ? 32 : 64; + const std::size_t header_size = options.header ? options.header->size() : 0; + + const auto encoded_path_size = checked_u8(path.size(), "path length"); + const auto encoded_header_size = checked_u8(header_size, "header length"); + auto encoded_offset = checked_u8(data_offset, "data offset"); + auto encoded_body_size = checked_u8(payload.size() + header_size, "body length"); + if (options.version == renpy_version::rpa_3_0) { + encoded_offset ^= options.encryption_key; + encoded_body_size ^= options.encryption_key; + } + + byte_buffer pickle; + pickle.push_back('}'); + pickle.push_back('U'); + pickle.push_back(encoded_path_size); + append_bytes(pickle, path); + pickle.push_back(']'); + pickle.push_back('K'); + pickle.push_back(encoded_offset); + pickle.push_back('K'); + pickle.push_back(encoded_body_size); + if (options.header) { + pickle.push_back('C'); + pickle.push_back(encoded_header_size); + append_bytes(pickle, *options.header); + } else if (options.include_none_header) { + pickle.push_back('N'); + } + pickle.push_back(options.header || options.include_none_header ? tuple3 : tuple2); + pickle.push_back('a'); + pickle.push_back('s'); + pickle.push_back('.'); + + return build_renpy_archive(pickle, payload, options.version, options.encryption_key, data_offset); + } + + byte_buffer make_renpy_archive_with_index(const std::span pickle_index, + const std::string_view payload) + { + return build_renpy_archive(pickle_index, payload, renpy_version::rpa_2_0, 0, 32); + } + + byte_buffer make_renpy_archive_with_expanded_index(const std::size_t expanded_size) + { + if (expanded_size == 0) { + throw std::runtime_error("Expanded Ren'Py index fixture must not be empty"); + } + return make_renpy_archive_with_index(byte_buffer(expanded_size, static_cast('N'))); + } +} // namespace test::support diff --git a/src/tests/support/archive_fixtures.h b/src/tests/support/archive_fixtures.h new file mode 100644 index 0000000..72bb3d4 --- /dev/null +++ b/src/tests/support/archive_fixtures.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace test::support +{ + using byte_buffer = std::vector; + + enum class renpy_version : std::uint8_t + { + rpa_2_0, + rpa_3_0, + }; + + struct renpy_archive_options final + { + renpy_version version = renpy_version::rpa_2_0; + std::uint8_t encryption_key = 0x5a; + std::optional header; + bool include_none_header = false; + }; + + [[nodiscard]] byte_buffer make_renpy_archive(std::string_view path, std::string_view payload); + [[nodiscard]] byte_buffer make_renpy_archive(std::string_view path, std::string_view payload, + const renpy_archive_options &options); + [[nodiscard]] byte_buffer make_renpy_archive_with_index(std::span pickle_index, + std::string_view payload = {}); + [[nodiscard]] byte_buffer make_renpy_archive_with_expanded_index(std::size_t expanded_size); + [[nodiscard]] byte_buffer make_rpgmaker_archive(std::string_view path, std::string_view payload); + [[nodiscard]] byte_buffer make_zanzarah_archive(std::string_view path, std::string_view payload); + [[nodiscard]] byte_buffer make_zanzarah_archive_with_entries(std::size_t entry_count); + + class temporary_archive final + { + public: + temporary_archive(std::string_view label, std::span contents); + ~temporary_archive(); + + temporary_archive(const temporary_archive &) = delete; + temporary_archive &operator=(const temporary_archive &) = delete; + temporary_archive(temporary_archive &&) = delete; + temporary_archive &operator=(temporary_archive &&) = delete; + + [[nodiscard]] const std::filesystem::path &path() const noexcept; + + private: + std::filesystem::path path_; + }; + + class temporary_sparse_renpy_archive final + { + public: + temporary_sparse_renpy_archive(); + ~temporary_sparse_renpy_archive(); + + temporary_sparse_renpy_archive(const temporary_sparse_renpy_archive &) = delete; + temporary_sparse_renpy_archive &operator=(const temporary_sparse_renpy_archive &) = delete; + temporary_sparse_renpy_archive(temporary_sparse_renpy_archive &&) = delete; + temporary_sparse_renpy_archive &operator=(temporary_sparse_renpy_archive &&) = delete; + + [[nodiscard]] const std::filesystem::path &path() const noexcept; + + private: + std::filesystem::path path_; + }; +} // namespace test::support diff --git a/src/tests/support/zlib_fixture.cpp b/src/tests/support/zlib_fixture.cpp new file mode 100644 index 0000000..f3847dc --- /dev/null +++ b/src/tests/support/zlib_fixture.cpp @@ -0,0 +1,27 @@ +#include "zlib_fixture.h" + +#include +#include + +#include + +namespace test::support +{ + std::vector compress_zlib_fixture(const std::span input) + { + if (input.size() > std::numeric_limits::max()) { + throw std::runtime_error("Fixture input is too large for zlib compression"); + } + + auto output_size = compressBound(static_cast(input.size())); + std::vector output(output_size); + const auto result = + compress2(reinterpret_cast(output.data()), &output_size, + reinterpret_cast(input.data()), static_cast(input.size()), Z_BEST_SPEED); + if (result != Z_OK) { + throw std::runtime_error("Failed to compress zlib fixture"); + } + output.resize(output_size); + return output; + } +} // namespace test::support diff --git a/src/tests/support/zlib_fixture.h b/src/tests/support/zlib_fixture.h new file mode 100644 index 0000000..bb6c9a6 --- /dev/null +++ b/src/tests/support/zlib_fixture.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include +#include + +namespace test::support +{ + [[nodiscard]] std::vector compress_zlib_fixture(std::span input); +} diff --git a/src/tests/unit/bounded_stream.cpp b/src/tests/unit/bounded_stream.cpp new file mode 100644 index 0000000..7dcee06 --- /dev/null +++ b/src/tests/unit/bounded_stream.cpp @@ -0,0 +1,226 @@ +#include "../../core/io/bounded_stream.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ + class controlled_stream_buffer final : public std::streambuf + { + public: + explicit controlled_stream_buffer(const std::string_view contents, const std::streamoff position = 0) + : contents_(contents), position_(position) + { + } + + void fail_seeks(const bool value) noexcept + { + fail_seeks_ = value; + } + + void limit_reads(const bool value) noexcept + { + limit_reads_ = value; + } + + void set_position(const std::streamoff value) noexcept + { + position_ = value; + } + + protected: + pos_type seekoff(const off_type offset, const std::ios_base::seekdir direction, + const std::ios_base::openmode mode) override + { + if (fail_seeks_ || (mode & std::ios_base::in) == 0) { + return pos_type{off_type{-1}}; + } + + off_type base = 0; + if (direction == std::ios_base::cur) { + base = position_; + } else if (direction == std::ios_base::end) { + base = static_cast(contents_.size()); + } + const auto result = base + offset; + if (result < 0) { + return pos_type{off_type{-1}}; + } + position_ = result; + return pos_type{position_}; + } + + pos_type seekpos(const pos_type position, const std::ios_base::openmode mode) override + { + if (fail_seeks_ || (mode & std::ios_base::in) == 0 || position < 0) { + return pos_type{off_type{-1}}; + } + position_ = static_cast(position); + return pos_type{position_}; + } + + std::streamsize xsgetn(char_type *destination, const std::streamsize count) override + { + if (count <= 0 || position_ < 0 || position_ >= static_cast(contents_.size())) { + return 0; + } + const auto available = static_cast(contents_.size() - static_cast(position_)); + auto copied = std::min(count, available); + if (limit_reads_) { + copied = std::min(copied, 1); + } + std::memcpy(destination, contents_.data() + position_, static_cast(copied)); + position_ += copied; + return copied; + } + + private: + std::string contents_; + std::streamoff position_ = 0; + bool fail_seeks_ = false; + bool limit_reads_ = false; + }; +} // namespace + +TEST_CASE("bounded stream: reads and rejects out-of-range operations") +{ + std::istringstream source("abcd"); + observer::io::bounded_stream input(source); + REQUIRE(input.size() == 4); + REQUIRE(input.position() == 0); + + input.seek_absolute(1); + REQUIRE(input.remaining() == 3); + std::array value{}; + input.read_exact(value.data(), value.size()); + REQUIRE(value == std::array{'b', 'c'}); + REQUIRE(input.remaining() == 1); + + REQUIRE_THROWS_AS(input.read_exact(value.data(), value.size()), observer::io::read_error); + REQUIRE_THROWS_AS(input.seek_absolute(-1), observer::io::read_error); + REQUIRE_THROWS_AS(input.seek_absolute(5), observer::io::read_error); +} + +TEST_CASE("bounded stream: reads trivial values") +{ + std::istringstream source(std::string{"\x78\x56\x34\x12", 4}); + observer::io::bounded_stream input(source); + REQUIRE(input.read_trivial() == 0x12345678); +} + +TEST_CASE("bounded stream: normalizes stream positioning failures") +{ + SECTION("invalid initial position") + { + controlled_stream_buffer buffer("abc", -1); + std::istream source(&buffer); + REQUIRE_THROWS_AS(observer::io::bounded_stream(source), observer::io::read_error); + } + + SECTION("end precedes initial position") + { + controlled_stream_buffer buffer("", 1); + std::istream source(&buffer); + REQUIRE_THROWS_AS(observer::io::bounded_stream(source), observer::io::read_error); + } + + SECTION("constructor receives an exception") + { + controlled_stream_buffer buffer("abc"); + buffer.fail_seeks(true); + std::istream source(&buffer); + source.exceptions(std::ios_base::failbit); + REQUIRE_THROWS_AS(observer::io::bounded_stream(source), observer::io::read_error); + } + + SECTION("position is negative") + { + std::istringstream source("abc"); + observer::io::bounded_stream input(source); + source.setstate(std::ios_base::failbit); + REQUIRE_THROWS_AS(input.position(), observer::io::read_error); + } + + SECTION("position exceeds the captured size") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.set_position(4); + REQUIRE_THROWS_AS(input.position(), observer::io::read_error); + } + + SECTION("position receives an exception") + { + std::istringstream source("abc"); + observer::io::bounded_stream input(source); + source.exceptions(std::ios_base::failbit); + REQUIRE_THROWS_AS(source.setstate(std::ios_base::failbit), std::ios_base::failure); + REQUIRE_THROWS_AS(input.position(), observer::io::read_error); + } +} + +TEST_CASE("bounded stream: normalizes seek and read failures") +{ + SECTION("seek reports a failed stream state") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.fail_seeks(true); + REQUIRE_THROWS_AS(input.seek_absolute(0), observer::io::read_error); + } + + SECTION("seek throws") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.fail_seeks(true); + source.exceptions(std::ios_base::failbit); + REQUIRE_THROWS_AS(input.seek_absolute(0), observer::io::read_error); + } + + SECTION("read is shorter than the captured extent") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.limit_reads(true); + std::array output{}; + REQUIRE_THROWS_AS(input.read_exact(output.data(), output.size()), observer::io::read_error); + } + + SECTION("read throws") + { + controlled_stream_buffer buffer("abc"); + std::istream source(&buffer); + observer::io::bounded_stream input(source); + buffer.limit_reads(true); + source.exceptions(std::ios_base::failbit); + std::array output{}; + REQUIRE_THROWS_AS(input.read_exact(output.data(), output.size()), observer::io::read_error); + } + + SECTION("requested size cannot be represented by the stream API") + { + std::istringstream source("abc"); + observer::io::bounded_stream input(source); + if constexpr (std::numeric_limits::max() > + static_cast(std::numeric_limits::max())) { + constexpr auto impossible_size = static_cast(std::numeric_limits::max()) + 1; + REQUIRE_THROWS_AS(input.read_exact(nullptr, impossible_size), observer::io::read_error); + } else { + SUCCEED("size_t cannot represent a request larger than streamsize on this ABI"); + } + } +} diff --git a/src/tests/unit/pickle.cpp b/src/tests/unit/pickle.cpp new file mode 100644 index 0000000..26a35fb --- /dev/null +++ b/src/tests/unit/pickle.cpp @@ -0,0 +1,249 @@ +#include "../../modules/renpy/pickle.h" +#include "../../archive.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ + template pickle::value_ptr load(const std::array &input) + { + return pickle::loads(std::span{ + reinterpret_cast(input.data()), + input.size(), + }); + } + + pickle::value_ptr load(const std::initializer_list input) + { + const std::vector bytes(input); + return pickle::loads(std::span{ + reinterpret_cast(bytes.data()), + bytes.size(), + }); + } + + pickle::value_ptr load_memo_copy(const std::initializer_list encoded_value) + { + std::vector bytes{']'}; + bytes.insert(bytes.end(), encoded_value); + const std::array suffix{'q', 0, 'a', 'h', 0, 'a'}; + bytes.insert(bytes.end(), suffix.begin(), suffix.end()); + bytes.push_back('.'); + return pickle::loads(std::span{ + reinterpret_cast(bytes.data()), + bytes.size(), + }); + } +} // namespace + +TEST_CASE("pickle: scalar values") +{ + const auto none = pickle::loads(std::string{"N."}); + REQUIRE(none->get_type() == pickle::value::type::none); + + const auto true_value = load(std::array{0x80, 0x04, 0x88, '.'}); + REQUIRE(true_value->get_type() == pickle::value::type::bool_); + REQUIRE(true_value->as_bool()); + + const auto false_value = load(std::array{0x80, 0x04, 0x89, '.'}); + REQUIRE(false_value->get_type() == pickle::value::type::bool_); + REQUIRE_FALSE(false_value->as_bool()); + + const auto integer = load(std::array{'K', 42, '.'}); + REQUIRE(integer->get_type() == pickle::value::type::int64); + REQUIRE(integer->as_int64() == 42); + + const auto float_value = load(std::array{'G', 0x3f, 0xf8, 0, 0, 0, 0, 0, 0, '.'}); + REQUIRE(float_value->as_float64() == 1.5); +} + +TEST_CASE("pickle: strings and lists") +{ + const auto string = load(std::array{'U', 3, 'f', 'o', 'o', '.'}); + REQUIRE(string->get_type() == pickle::value::type::string); + REQUIRE(string->as_string() == "foo"); + + const auto list = load(std::array{']', '(', 'K', 1, 'K', 2, 'e', '.'}); + REQUIRE(list->get_type() == pickle::value::type::list); + REQUIRE(list->as_list().size() == 2); + REQUIRE(list->as_list()[0]->as_int64() == 1); + REQUIRE(list->as_list()[1]->as_int64() == 2); +} + +TEST_CASE("pickle: integer and protocol encodings") +{ + REQUIRE(load({'J', 0x78, 0x56, 0x34, 0x12, '.'})->as_int64() == 0x12345678); + REQUIRE(load({'J', 0xff, 0xff, 0xff, 0xff, '.'})->as_int64() == -1); + REQUIRE(load({'M', 0x34, 0x12, '.'})->as_int64() == 0x1234); + REQUIRE(load({'I', '4', '2', '\n', '.'})->as_int64() == 42); + REQUIRE(load({'I', '-', '4', '2', 'L', '\n', '.'})->as_int64() == -42); + + REQUIRE(load({0x80, 4, 0x95, 0, 0, 0, 0, 0, 0, 0, 0, 'K', 7, '.'})->as_int64() == 7); +} + +TEST_CASE("pickle: string and byte encodings") +{ + const auto bin_string = load({'T', 3, 0, 0, 0, 'f', 'o', 'o', '.'}); + REQUIRE(bin_string->get_type() == pickle::value::type::string); + REQUIRE(bin_string->as_string() == "foo"); + + REQUIRE(load({0x8c, 3, 'b', 'a', 'r', '.'})->as_string() == "bar"); + REQUIRE(load({'X', 3, 0, 0, 0, 'b', 'a', 'z', '.'})->as_string() == "baz"); + + const auto short_bytes = load({'C', 2, 0, 0xff, '.'}); + REQUIRE(short_bytes->get_type() == pickle::value::type::bytes); + REQUIRE(short_bytes->as_string() == std::string{"\0\xff", 2}); + + const auto bin_bytes = load({'B', 3, 0, 0, 0, 1, 2, 3, '.'}); + REQUIRE(bin_bytes->get_type() == pickle::value::type::bytes); + REQUIRE(bin_bytes->as_string() == std::string{"\1\2\3", 3}); +} + +TEST_CASE("pickle: list and tuple encodings") +{ + REQUIRE(load({']', 'K', 1, 'a', '.'})->as_list()[0]->as_int64() == 1); + REQUIRE(load({'(', 'K', 1, 'K', 2, 'l', '.'})->as_list().size() == 2); + + REQUIRE(load({')', '.'})->as_tuple().empty()); + REQUIRE(load({'(', 'K', 1, 't', '.'})->as_tuple()[0]->as_int64() == 1); + REQUIRE(load({'K', 1, 0x85, '.'})->as_tuple()[0]->as_int64() == 1); + + const auto tuple2 = load({'K', 1, 'K', 2, 0x86, '.'}); + REQUIRE(tuple2->as_tuple().size() == 2); + REQUIRE(tuple2->as_tuple()[1]->as_int64() == 2); + + const auto tuple3 = load({'K', 1, 'K', 2, 'K', 3, 0x87, '.'}); + REQUIRE(tuple3->as_tuple().size() == 3); + REQUIRE(tuple3->as_tuple()[2]->as_int64() == 3); +} + +TEST_CASE("pickle: dictionary encodings") +{ + REQUIRE(load({'}', '.'})->as_dict().empty()); + + const auto dict = load({'(', 'U', 1, 'a', 'K', 1, 'd', '.'}); + REQUIRE(dict->as_dict().at("a")->as_int64() == 1); + + const auto setitem = load({'}', 'U', 1, 'a', 'K', 2, 's', '.'}); + REQUIRE(setitem->as_dict().at("a")->as_int64() == 2); + + const auto setitems = load({'}', '(', 'U', 1, 'a', 'K', 3, 'U', 1, 'b', 'K', 4, 'u', '.'}); + REQUIRE(setitems->as_dict().size() == 2); + REQUIRE(setitems->as_dict().at("a")->as_int64() == 3); + REQUIRE(setitems->as_dict().at("b")->as_int64() == 4); +} + +TEST_CASE("pickle: LONG1 integers") +{ + REQUIRE(load(std::array{0x8a, 0, '.'})->as_int64() == 0); + REQUIRE(load(std::array{0x8a, 1, 0x7f, '.'})->as_int64() == 127); + REQUIRE(load(std::array{0x8a, 1, 0xff, '.'})->as_int64() == -1); + REQUIRE(load({0x8a, 8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, '.'})->as_int64() == INT64_MAX); + + REQUIRE_THROWS(load(std::array{0x8a, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, '.'})); +} + +TEST_CASE("pickle: malformed input is rejected") +{ + REQUIRE_THROWS(pickle::loads(std::string{})); + REQUIRE_THROWS(pickle::loads(std::string{"?."})); + REQUIRE_THROWS(pickle::loads(std::string{"I\n"})); + + REQUIRE_THROWS(load({'M', 1})); + REQUIRE_THROWS(load({'J', 1, 2, 3})); + REQUIRE_THROWS(load({'G', 0, 0, 0, 0, 0, 0, 0})); + REQUIRE_THROWS(load({'U', 2, 'x'})); + REQUIRE_THROWS(load({'I', '1'})); + REQUIRE_THROWS(load({'N', 'N', '.'})); + + REQUIRE_THROWS(load({'a'})); + REQUIRE_THROWS(load({'N', 'K', 1, 'a'})); + REQUIRE_THROWS(load({'(', 'K', 1, 'e'})); + REQUIRE_THROWS_AS(load({']', 'N', '(', 'a', 'l'}), std::runtime_error); + REQUIRE_THROWS(load({'N', '(', 'K', 1, 'e'})); + REQUIRE_THROWS(load({'l'})); + + REQUIRE_THROWS(load({0x85})); + REQUIRE_THROWS(load({'K', 1, 0x86})); + REQUIRE_THROWS(load({'K', 1, 'K', 2, 0x87})); + + REQUIRE_THROWS(load({'(', 'U', 1, 'a', 'd'})); + REQUIRE_THROWS(load({'(', 'K', 1, 'K', 2, 'd'})); + REQUIRE_THROWS(load({'s'})); + REQUIRE_THROWS(load({'N', 'U', 1, 'a', 'K', 1, 's'})); + REQUIRE_THROWS(load({'}', 'K', 1, 'K', 2, 's'})); + REQUIRE_THROWS(load({'}', '(', 'U', 1, 'a', 'u'})); + REQUIRE_THROWS(load({'(', 'U', 1, 'a', 'K', 1, 'u'})); + REQUIRE_THROWS(load({'N', '(', 'U', 1, 'a', 'K', 1, 'u'})); + REQUIRE_THROWS(load({'}', '(', 'K', 1, 'K', 2, 'u'})); + + REQUIRE_THROWS(load({'q'})); + REQUIRE_THROWS(load({'q', 0})); + REQUIRE_THROWS(load({'r', 0, 0, 0, 0})); + REQUIRE_THROWS(load({'h', 0})); + REQUIRE_THROWS(load({'j', 0, 0, 0, 0})); + REQUIRE_THROWS(load({0x94})); + REQUIRE_THROWS(load({0x80})); + REQUIRE_THROWS(load({0x95, 0, 0, 0, 0, 0, 0, 0})); + REQUIRE_THROWS(load({0x8a})); + REQUIRE_THROWS(load({0x8a, 1})); + + const auto none = pickle::value::none(); + REQUIRE_THROWS(none->as_bool()); + REQUIRE_THROWS(none->as_int64()); + REQUIRE_THROWS(none->as_float64()); + REQUIRE_THROWS(none->as_string()); + REQUIRE_THROWS(none->as_list()); + REQUIRE_THROWS(none->as_tuple()); + REQUIRE_THROWS(none->as_dict()); +} + +TEST_CASE("pickle: memo references preserve values") +{ + const auto list = load(std::array{']', 'K', 42, 'q', 7, 'a', 'h', 7, 'a', '.'}); + + REQUIRE(list->as_list().size() == 2); + REQUIRE(list->as_list()[0]->as_int64() == 42); + REQUIRE(list->as_list()[1]->as_int64() == 42); + + const auto long_memo = load({']', 'K', 9, 'r', 1, 0, 0, 0, 'a', 'j', 1, 0, 0, 0, 'a', '.'}); + REQUIRE(long_memo->as_list()[1]->as_int64() == 9); + + const auto auto_memo = load({']', 'K', 8, 0x94, 'a', 'h', 0, 'a', '.'}); + REQUIRE(auto_memo->as_list()[1]->as_int64() == 8); +} + +TEST_CASE("pickle: memo copies all supported value types") +{ + REQUIRE(load_memo_copy({'N'})->as_list()[1]->get_type() == pickle::value::type::none); + REQUIRE(load_memo_copy({0x88})->as_list()[1]->as_bool()); + REQUIRE(load_memo_copy({'K', 2})->as_list()[1]->as_int64() == 2); + REQUIRE(load_memo_copy({'G', 0x3f, 0xf0, 0, 0, 0, 0, 0, 0})->as_list()[1]->as_float64() == 1.0); + REQUIRE(load_memo_copy({'C', 1, 'b'})->as_list()[1]->get_type() == pickle::value::type::bytes); + REQUIRE(load_memo_copy({'U', 1, 's'})->as_list()[1]->get_type() == pickle::value::type::string); + REQUIRE(load_memo_copy({']', 'K', 1, 'a'})->as_list()[1]->as_list()[0]->as_int64() == 1); + REQUIRE(load_memo_copy({'}', 'U', 1, 'k', 'K', 1, 's'})->as_list()[1]->as_dict().at("k")->as_int64() == 1); + REQUIRE(load_memo_copy({'K', 1, 0x85})->as_list()[1]->as_tuple()[0]->as_int64() == 1); + + const pickle::value invalid(std::bit_cast(UINT8_MAX)); + REQUIRE_THROWS(pickle::clone(invalid)); +} + +TEST_CASE("archive: typed failures are standard exceptions") +{ + REQUIRE(std::string_view(archive::read_error{}.what()).empty()); + REQUIRE(std::string_view(archive::write_error{}.what()).empty()); + REQUIRE(std::string_view(archive::user_interrupt{}.what()).empty()); + REQUIRE(std::string_view(extractor::read_error{}.what()).empty()); + + const auto base = std::make_unique(); + REQUIRE(base != nullptr); +} diff --git a/src/tests/unit/zlib.cpp b/src/tests/unit/zlib.cpp new file mode 100644 index 0000000..72110ed --- /dev/null +++ b/src/tests/unit/zlib.cpp @@ -0,0 +1,118 @@ +#include "../../core/compression/zlib_codec.h" +#include "../support/zlib_fixture.h" + +#include +#include +#include + +#ifdef _DEBUG +#include +#endif + +#include + +namespace +{ +#ifdef _DEBUG + thread_local bool reject_next_allocation = false; + + int __cdecl allocation_hook(const int allocation_type, void *, const std::size_t, const int, const long, + const unsigned char *, const int) + { + if (allocation_type == _HOOK_ALLOC && reject_next_allocation) { + reject_next_allocation = false; + return 0; + } + return 1; + } + + class scoped_allocation_failure final + { + public: + scoped_allocation_failure() : previous_(_CrtSetAllocHook(allocation_hook)) + { + reject_next_allocation = true; + } + + ~scoped_allocation_failure() + { + reject_next_allocation = false; + static_cast(_CrtSetAllocHook(previous_)); + } + + scoped_allocation_failure(const scoped_allocation_failure &) = delete; + scoped_allocation_failure &operator=(const scoped_allocation_failure &) = delete; + + private: + _CRT_ALLOC_HOOK previous_ = nullptr; + }; + + void decompress_with_failed_initial_allocation(const std::span input) + { + scoped_allocation_failure failure; + static_cast(observer::compression::decompress_zlib(input, 1)); + } +#endif +} // namespace + +TEST_CASE("compression: zlib round trip and output budget") +{ + constexpr std::array input{ + std::byte{0x6f}, std::byte{0x62}, std::byte{0x73}, std::byte{0x65}, + std::byte{0x72}, std::byte{0x76}, std::byte{0x65}, std::byte{0x72}, + }; + const auto compressed = test::support::compress_zlib_fixture(input); + REQUIRE(observer::compression::decompress_zlib(compressed, input.size()) == + std::vector(input.begin(), input.end())); + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(compressed, input.size() - 1), + observer::compression::error); + + auto corrupted = compressed; + corrupted.back() ^= std::byte{0xff}; + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(corrupted, input.size()), observer::compression::error); + REQUIRE_THROWS_AS(observer::compression::decompress_zlib({}, input.size()), observer::compression::error); +} + +TEST_CASE("compression: zlib consumes multiple output chunks and rejects trailing or truncated input") +{ + std::vector input(std::size_t{192} * 1024); + std::uint32_t state = 0x9e3779b9; + for (auto &byte : input) { + state = state * 1664525 + 1013904223; + byte = static_cast(state >> 24); + } + + const auto compressed = test::support::compress_zlib_fixture(input); + REQUIRE(observer::compression::decompress_zlib(compressed, input.size()) == input); + + auto trailing = compressed; + trailing.push_back(std::byte{0}); + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(trailing, input.size()), observer::compression::error); + + auto truncated = compressed; + truncated.pop_back(); + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(truncated, input.size()), observer::compression::error); +} + +TEST_CASE("compression: zlib supports an empty payload") +{ + const auto compressed = test::support::compress_zlib_fixture({}); + REQUIRE(observer::compression::decompress_zlib(compressed, 0).empty()); +} + +TEST_CASE("compression: zlib normalizes a stream that requires a preset dictionary") +{ + constexpr std::array compressed_with_dictionary{ + std::byte{0x78}, std::byte{0xbb}, std::byte{0}, std::byte{0}, std::byte{0}, std::byte{1}, + }; + REQUIRE_THROWS_AS(observer::compression::decompress_zlib(compressed_with_dictionary, 0), + observer::compression::error); +} + +#ifdef _DEBUG +TEST_CASE("compression: zlib initialization failure is normalized") +{ + const auto compressed = test::support::compress_zlib_fixture(std::array{std::byte{1}}); + REQUIRE_THROWS_AS(decompress_with_failed_initial_allocation(compressed), observer::compression::error); +} +#endif diff --git a/src/tests/zanzarah.cpp b/src/tests/zanzarah.cpp index a087276..7a6f7a2 100644 --- a/src/tests/zanzarah.cpp +++ b/src/tests/zanzarah.cpp @@ -4,17 +4,17 @@ using namespace test; -TEST_CASE("zanzarah: zanzarah1") +TEST_CASE("zanzarah: zanzarah1", "[compatibility][.]") { - test_on("zanzarah\\zanzarah1.pak"); + test_external_archive("zanzarah\\zanzarah1.pak"); } -TEST_CASE("zanzarah: zanzarah2") +TEST_CASE("zanzarah: zanzarah2", "[compatibility][.]") { - test_on("zanzarah\\zanzarah2.pak"); + test_external_archive("zanzarah\\zanzarah2.pak"); } -TEST_CASE("zanzarah: zanzarah3") +TEST_CASE("zanzarah: zanzarah3", "[compatibility][.]") { - test_on("zanzarah\\zanzarah3.pak"); + test_external_archive("zanzarah\\zanzarah3.pak"); } diff --git a/vcpkg.json b/vcpkg.json index 5b8ac59..4efc9ef 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,22 +1,24 @@ { - "name" : "observer-modules", - "version-string" : "1.0.0", - "license" : "LGPL-3.0-or-later", - "dependencies" : [ { - "name" : "zlib", - "version>=" : "1.3.1" - }, { - "name" : "zstr", - "version>=" : "1.0.7" - }, { - "name" : "catch2", - "version>=" : "3.8.1" - }, { - "name" : "nlohmann-json", - "version>=" : "3.12.0" - }, { - "name" : "xxhash", - "version>=" : "0.8.3" - } ], - "builtin-baseline" : "2cbe970e2c987a55848ba3c6b1c1d285f220159e" -} \ No newline at end of file + "name": "observer-modules", + "version-string": "1.0.0", + "license": "LGPL-3.0-or-later", + "builtin-baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d", + "dependencies": [ + { + "name": "catch2", + "version>=": "3.15.3" + }, + { + "name": "nlohmann-json", + "version>=": "3.12.0" + }, + { + "name": "xxhash", + "version>=": "0.8.3" + }, + { + "name": "zlib", + "version>=": "1.3.2" + } + ] +}